Merge pull request #2543 from splichy/fix/provider-scoped-stream-routing

fix(model-registry): scope custom provider stream handlers to prevent clobbering built-ins
This commit is contained in:
TÂCHES 2026-03-25 15:44:55 -06:00 committed by GitHub
commit 9a32ea9c17
2 changed files with 99 additions and 2 deletions

View file

@ -572,3 +572,73 @@ describe("ModelRegistry authMode — streamSimple apiKey boundary", () => {
assert.equal((captured as Record<string, unknown>).reasoning, "high", "reasoning must pass through");
});
});
// ─── Provider-scoped stream routing (#2533) ───────────────────────────────────
describe("ModelRegistry authMode — provider-scoped stream routing", () => {
it("does not clobber built-in stream handler when custom provider uses same api", () => {
const registry = createRegistry(() => true);
const customSpy = createStreamSpy();
// Register a custom provider with the same API type as a built-in (anthropic-messages).
// This simulates the claude-code-cli extension registering with api: "anthropic-messages".
registry.registerProvider("custom-cli", {
authMode: "externalCli",
baseUrl: "local://custom",
api: "anthropic-messages",
streamSimple: customSpy.streamSimple,
models: [createProviderModel("custom-model", "anthropic-messages")],
});
// The built-in anthropic-messages provider should still be accessible
// when calling streamSimple with a model from the built-in provider.
const provider = getApiProvider("anthropic-messages" as Api);
assert.ok(provider, "anthropic-messages provider must still be registered");
// Call with a built-in anthropic model — should NOT hit the custom spy.
// The built-in handler will throw (no API key), which proves the routing
// correctly delegates to the built-in instead of the custom handler.
assert.throws(
() => provider.streamSimple(
makeModel("anthropic", "claude-sonnet-4-6", "anthropic-messages"),
makeContext(),
{ maxTokens: 4096 } as SimpleStreamOptions,
),
(err: Error) => err.message.includes("API key"),
"built-in Anthropic handler must be invoked (throws because no API key in tests)",
);
assert.equal(
customSpy.getCapturedOptions(),
undefined,
"custom provider's streamSimple must NOT be called for anthropic provider models",
);
});
it("routes to custom provider when model.provider matches", () => {
const registry = createRegistry(() => true);
const customSpy = createStreamSpy();
registry.registerProvider("custom-cli", {
authMode: "externalCli",
baseUrl: "local://custom",
api: "anthropic-messages",
streamSimple: customSpy.streamSimple,
models: [createProviderModel("custom-model", "anthropic-messages")],
});
const provider = getApiProvider("anthropic-messages" as Api);
assert.ok(provider);
// Call with the custom provider's model — should hit the custom spy
provider.streamSimple(
makeModel("custom-cli", "custom-model", "anthropic-messages"),
makeContext(),
{ maxTokens: 2048 } as SimpleStreamOptions,
);
const captured = customSpy.getCapturedOptions();
assert.ok(captured, "custom provider's streamSimple must be called for its own models");
assert.equal(captured.maxTokens, 2048);
});
});

View file

@ -6,6 +6,7 @@ import {
type Api,
type AssistantMessageEventStream,
type Context,
getApiProvider,
getModels,
getProviders,
type KnownProvider,
@ -635,11 +636,37 @@ export class ModelRegistry {
})
: rawStreamSimple;
// Guard: if there's already a handler registered for this API, wrap
// the new one so it only fires for models from this provider and
// delegates to the previous handler for all other providers. Without
// this, a custom provider using api:"anthropic-messages" would clobber
// the built-in Anthropic stream handler (#2536).
const existingProvider = getApiProvider(config.api as Api);
const scopedStream = existingProvider
? (model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream => {
if (model.provider === providerName) {
return streamSimple(model, context, options);
}
return existingProvider.streamSimple(model, context, options);
}
: streamSimple;
const newFullStream = (model: Model<Api>, context: Context, options?: SimpleStreamOptions) =>
scopedStream(model, context, options as SimpleStreamOptions);
const scopedFullStream = existingProvider
? (model: Model<Api>, context: Context, options?: Record<string, unknown>) => {
if (model.provider === providerName) {
return newFullStream(model, context, options as SimpleStreamOptions);
}
return existingProvider.stream(model, context, options);
}
: newFullStream;
registerApiProvider(
{
api: config.api,
stream: (model, context, options) => streamSimple(model, context, options as SimpleStreamOptions),
streamSimple,
stream: scopedFullStream as any,
streamSimple: scopedStream,
},
`provider:${providerName}`,
);