Skip to content

fix: clone endpoint to preserve tokenizer getter (fixes #321945)#321947

Open
vs-code-engineering[bot] wants to merge 1 commit into
mainfrom
error-fix/321945-tokenizer-undefined-spread-4e98380acfbab688
Open

fix: clone endpoint to preserve tokenizer getter (fixes #321945)#321947
vs-code-engineering[bot] wants to merge 1 commit into
mainfrom
error-fix/321945-tokenizer-undefined-spread-4e98380acfbab688

Conversation

@vs-code-engineering

Copy link
Copy Markdown
Contributor

Summary

CopilotLanguageModelWrapper._provideLanguageModelResponse crashes with Error: Unknown tokenizer: undefined when it processes a request for an endpoint whose tokenizer is implemented as a prototype getter rather than an own property.

The offending line built the prompt-rendering endpoint with a JavaScript object spread:

PromptRenderer.create(this._instantiationService, {
    ..._endpoint,
    modelMaxPromptTokens: tokenLimit
}, ...)

Object spread copies only own enumerable properties. Prototype accessors (getters) are not own properties, so they are dropped. For ChatEndpoint instances tokenizer is an own property (assigned in the constructor as ... ?? TokenizerType.O200K), so the spread happened to preserve it. But for getter-based endpoints — most commonly ExtensionContributedChatEndpoint, whose get tokenizer() returns TokenizerType.O200K — the spread strips the getter and yields an object with tokenizer === undefined. That object is typed by TypeScript as a complete IChatEndpoint (the type-system bypass), but at runtime it violates the non-nullable tokenizer: TokenizerType contract, and acquireTokenizer throws.

The fix replaces the spread with _endpoint.cloneWithTokenOverride(tokenLimit) — the purpose-built IChatEndpoint method that returns a real endpoint instance (prototype intact) with the max-prompt-tokens override applied.

Fixes #321945
Recommended reviewer: @lramos15

Culprit Commit

Not precisely identifiable from this checkout. The repository is a shallow clone (fetch-depth=1), so git blame and git log -S cannot pinpoint the commit that introduced the object spread; the spread predates the visible history window and is present in the shipped commit (v1.125.0).

Framing as a regression: the identical error message Unknown tokenizer: undefined was previously fixed for ChatEndpoint in #319620 (commit b7893470, ~June 2) by defaulting the tokenizer to O200K in the ChatEndpoint constructor. That fix stores tokenizer as an own property, so it survives the spread — but it does not protect endpoints that expose tokenizer as a prototype getter. The error resurfaced once getter-based endpoints (notably ExtensionContributedChatEndpoint, which is stored in _chatEndpoints from getAllChatEndpoints() and returned by _getEndpointForModel) began flowing through _provideLanguageModelResponse, exposing the long-standing spread bypass.

Code Flow

sequenceDiagram
    participant Ext as Extension-contributed LM
    participant LMA as CopilotLanguageModelWrapper
    participant PR as PromptRenderer
    participant TOK as acquireTokenizer

    Ext->>LMA: provideLanguageModelResponse(endpoint)
    Note over LMA: endpoint = ExtensionContributedChatEndpoint<br/>tokenizer is a prototype getter (O200K)
    LMA->>PR: create(_endpoint) [line 651, getter intact]
    PR-->>LMA: countTokens() OK (no crash)
    LMA->>LMA: tokenLimit = _endpoint.modelMaxPromptTokens - baseCount - ...
    LMA->>PR: create({ ..._endpoint, modelMaxPromptTokens: tokenLimit }) [line 659]
    Note over LMA,PR: spread copies own props only<br/>prototype getter dropped -> tokenizer === undefined
    PR->>TOK: acquireTokenizer(endpoint)
    TOK-->>Ext: throw Error("Unknown tokenizer: undefined")
Loading

Affected Files

  • extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.tsmodified. The bypass/producer at the second PromptRenderer.create in CopilotLanguageModelWrapper._provideLanguageModelResponse. The first create (a few lines above) passes _endpoint directly and never crashed, which matches the telemetry crashing only at the second call.
  • extensions/copilot/src/platform/tokenizer/node/tokenizer.tsnot modified (crash site). acquireTokenizer throws Unknown tokenizer: ${endpoint.tokenizer} when the value is undefined. Intentionally left untouched: the crash site reports where it fails, not why.
  • extensions/copilot/src/platform/endpoint/vscode-node/extChatEndpoint.tsnot modified (example producer). ExtensionContributedChatEndpoint exposes tokenizer, modelMaxPromptTokens, model, etc. as prototype getters, and its cloneWithTokenOverride correctly preserves them.
  • extensions/copilot/src/platform/networking/common/networking.tsnot modified (contract). Declares tokenizer: TokenizerType (non-nullable) and cloneWithTokenOverride(modelMaxPromptTokens: number): IChatEndpoint.

Repro Steps

  1. Enable an extension that contributes a language model through the VS Code LM API, so Copilot wraps it as an ExtensionContributedChatEndpoint (its tokenizer is a prototype getter).
  2. Issue a request against that contributed model so it routes through CopilotLanguageModelWrapper._provideLanguageModelResponse.
  3. The first PromptRenderer.create(...).countTokens() succeeds (endpoint passed directly, getter intact).
  4. The second PromptRenderer.create({ ..._endpoint, modelMaxPromptTokens: tokenLimit }).render() receives a spread copy whose tokenizer getter was stripped (undefined).
  5. Prompt rendering calls acquireTokenizer(endpoint), which throws Unknown tokenizer: undefined (telemetry bucket f969dd59-a13a-f94e-2982-6394d7b51e19).

How the Fix Works

Chosen approach (languageModelAccess.ts): replace the object-spread endpoint argument

{ ..._endpoint, modelMaxPromptTokens: tokenLimit }

with

_endpoint.cloneWithTokenOverride(tokenLimit)

This fixes the data producer (where the malformed endpoint object is created) rather than guarding the crash site — the data-producer principle. cloneWithTokenOverride is declared on the IChatEndpoint interface and implemented by every endpoint type, including the getter-based ones: ChatEndpoint clones its model metadata with max_prompt_tokens overridden, and ExtensionContributedChatEndpoint constructs a fresh instance with maxInputTokens overridden (its modelMaxPromptTokens getter then returns the new value). Because it returns a genuine endpoint instance, the tokenizer getter (and all other prototype members) remain present, so endpoint.tokenizer resolves to O200K instead of undefined. The override semantics are identical to what the spread intended (set modelMaxPromptTokens to tokenLimit), and this exact method is already used elsewhere in the same file for context-size overrides, so it is the idiomatic call. The crash site in tokenizer.ts and its logService/throw path are left untouched, so genuinely-malformed endpoints would still be surfaced to telemetry.

After this change, the second PromptRenderer.create call cannot produce an endpoint with tokenizer === undefined, because cloneWithTokenOverride returns a real IChatEndpoint instance whose tokenizer getter/property is preserved, instead of an object-spread copy that drops prototype getters.

Alternatives considered:

  • Adding a tokenizer ?? O200K fallback at the crash site in acquireTokenizer — rejected: it guards the symptom at the bottom of the stack and hides every future producer of a malformed endpoint from telemetry.
  • Converting ExtensionContributedChatEndpoint's getters to own properties so the spread copies them — rejected: it patches one consumer-visible symptom while leaving the spread bypass in place for the other getter-based endpoints (StreamingPassThroughEndpoint, ClaudeStreamingPassThroughEndpoint), and re-introduces the same class of bug for any future getter-based endpoint.

Recommended Owner

@lramos15 (Logan Ramos) — top contributor and most-recent author of languageModelAccess.ts (6 of the last 15 commits, including the most recent on the issue's ship date), working directly in the language-model-access / context-size area that owns cloneWithTokenOverride usage in this file, with demonstrated write access (direct merges to main).

Generated by errors-fix · 4.4K AIC · ⌖ 149.6 AIC · ⊞ 69.3K ·

Replace the object spread { ..._endpoint, modelMaxPromptTokens } with
_endpoint.cloneWithTokenOverride(tokenLimit) in
CopilotLanguageModelWrapper._provideLanguageModelResponse.

The spread copies only own enumerable properties, dropping prototype
getters such as tokenizer on getter-based endpoints (for example
ExtensionContributedChatEndpoint). That produced an IChatEndpoint whose
tokenizer was undefined and crashed acquireTokenizer with
"Unknown tokenizer: undefined". cloneWithTokenOverride returns a real
endpoint instance with the getter intact while still overriding the
max prompt tokens.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 18, 2026 15:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@vs-code-engineering vs-code-engineering Bot marked this pull request as ready for review June 18, 2026 15:44
@vs-code-engineering vs-code-engineering Bot enabled auto-merge (squash) June 18, 2026 15:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Regression] Unknown tokenizer: undefined

2 participants