Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/core/src/__tests__/model-web-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,24 @@ describe('hosted web search capability', () => {
),
null,
);
assert.equal(
resolveHostedWebSearchCapability(
'openai',
[{ id: 'gpt-4.1', capabilities: { webSearch: true } }],
'gpt-4.1',
),
null,
'capability metadata must not switch a Chat Completions model to Responses',
);
assert.equal(
resolveHostedWebSearchCapability(
'deepseek',
[{ id: 'deepseek-chat', capabilities: { webSearch: true } }],
'deepseek-chat',
),
null,
'capability metadata must not switch a DeepSeek Chat model to Responses',
);
assert.deepEqual(
resolveHostedWebSearchCapability(
'openai-responses-compatible',
Expand Down
31 changes: 27 additions & 4 deletions packages/core/src/model-web-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ export interface HostedWebSearchCapability {
/**
* Resolves provider-hosted search for one exact model.
*
* Stored model metadata wins when it explicitly declares webSearch. Unknown
* models then use narrow provider rules backed by the provider's public API
* contract. Unknown never means supported: sending an unsupported built-in
* tool can be silently ignored by several OpenAI-compatible providers.
* Stored model metadata can explicitly deny search, or grant it when the
* selected request wire can carry the provider tool. Unknown models use
* narrow provider rules backed by the provider's public API contract. Unknown
* never means supported: sending an unsupported built-in tool can be silently
* ignored by several OpenAI-compatible providers.
*
* The connection provider/wire is authoritative when a service supports both
* Responses and Anthropic Messages. Search never switches wire independently
Expand All @@ -47,11 +48,33 @@ export function resolveHostedWebSearchCapability(
return null;
}
if (stored?.capabilities?.webSearch === true) {
// A capability declaration does not select a request protocol. These
// providers expose both Chat Completions and Responses, so an omitted
// apiProtocol must still follow the same narrow model-family default as
// the model factory. Otherwise metadata such as `webSearch: true` on a
// Chat model would make Runtime compile a Responses-only provider tool for
// the wrong wire.
if (
stored.apiProtocol === undefined &&
adapter.adapter === 'openai-responses' &&
providerSelectsOpenAiWireByModel(providerType)
) {
return providerDefaultHostedWebSearchCapability(providerType, id, adapter);
Comment on lines +60 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Gpt-5 wire rules diverge 🐞 Bug ≡ Correctness

For an OpenAI model such as gpt-5foo with webSearch: true and no explicit protocol, this branch
returns null even though the model factory selects OpenAI Responses. Native web search is
therefore removed from a request whose effective wire supports it, violating the PR's wire-alignment
invariant.
Agent Prompt
## Issue description
The new hosted-search metadata guard delegates to provider-specific search patterns that do not exactly match the model factory's protocol authority. In particular, `openAiAdapterApiProtocol` classifies every ID beginning with `gpt-5` as Responses, while the search fallback requires a delimiter after `gpt-5`, causing explicit `webSearch: true` metadata to be ignored for IDs such as `gpt-5foo`.

## Issue Context
Consolidate rather than add another model-family rule: import and reuse `openAiAdapterApiProtocol` when this branch needs to determine the effective wire. Keep the separate provider-default search rules for models without affirmative capability metadata, since those encode provider search support rather than wire selection.

## Fix Focus Areas
- packages/core/src/model-web-search.ts[50-75]
- packages/core/src/model-metadata.ts[91-109]
- packages/core/src/__tests__/model-web-search.test.ts[77-97]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}
return adapter;
}
return providerDefaultHostedWebSearchCapability(providerType, id, adapter);
}

function providerSelectsOpenAiWireByModel(providerType: ProviderType): boolean {
return (
providerType === 'deepseek' ||
providerType === 'openai' ||
providerType === 'xai' ||
providerType === 'xai-oauth'
);
}

function providerHostedWebSearchAdapter(
providerType: ProviderType,
): HostedWebSearchCapability | null {
Expand Down
45 changes: 45 additions & 0 deletions packages/runtime/src/__tests__/model-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,51 @@ describe('ModelAdapter stream and error normalization', () => {
});
});

test('supports Responses reasoning replay for dual-wire OpenAI-compatible providers', () => {
const deepseek = new ModelAdapter({
connection: {
slug: 'deepseek',
providerType: 'deepseek',
defaultModel: 'deepseek-v4-flash',
},
apiKey: 'deepseek-token',
modelId: 'deepseek-v4-flash',
modelFactory: () => ({}),
newId: idGenerator(),
now: monotonicClock(),
});
const chat = new ModelAdapter({
connection: {
slug: 'deepseek',
providerType: 'deepseek',
defaultModel: 'deepseek-chat',
models: [{ id: 'deepseek-chat', apiProtocol: 'openai-chat' }],
},
apiKey: 'deepseek-token',
modelId: 'deepseek-chat',
modelFactory: () => ({}),
newId: idGenerator(),
now: monotonicClock(),
});
const explicitOpenAiChat = new ModelAdapter({
connection: {
slug: 'openai',
providerType: 'openai',
defaultModel: 'gpt-5.5',
models: [{ id: 'gpt-5.5', apiProtocol: 'openai-chat' }],
},
apiKey: 'openai-token',
modelId: 'gpt-5.5',
modelFactory: () => ({}),
newId: idGenerator(),
now: monotonicClock(),
});

assert.equal(deepseek.runtimeEventReplaySupport().openAiResponsesThinking, true);
assert.equal(chat.runtimeEventReplaySupport().openAiResponsesThinking, false);
assert.equal(explicitOpenAiChat.runtimeEventReplaySupport().openAiResponsesThinking, false);
});

test('translates provider text, reasoning, tool calls, and errors into ModelStreamEvents', () => {
const adapter = newAdapter();
type Chunk = Parameters<typeof adapter.translateChunk>[0];
Expand Down
26 changes: 20 additions & 6 deletions packages/runtime/src/model-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,12 +444,26 @@ function usesKimiOpenAiChat(connection: RuntimeExecutionConnection, modelId: str

function usesOpenAiResponses(connection: RuntimeExecutionConnection, modelId: string): boolean {

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.

P1 — Remove the stale parallel wire resolver after rebasing. Current main caches ResolvedModelRuntime and derives replay/encrypted-thinking behavior from that resolved contract; this PR's Runtime half conflicts with it and refers to the superseded thinking shape. Retaining this helper would recreate a second wire-precedence authority and can diverge from the model factory. Rebase and drop the Runtime source/test half; the Core resolver change is the minimal fix for this PR's problem.

const runtime = resolveModelRuntime(connection, modelId);
if (runtime.adapter.kind !== 'openai') return false;
return (
runtime.adapter.apiProtocol === 'openai-responses' ||
runtime.apiProtocol === 'openai-responses' ||
openAiAdapterApiProtocol(modelId, connection.providerType) === 'openai-responses'
);
switch (runtime.adapter.kind) {

@Astro-Han Astro-Han Aug 23, 2026

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.

[P1] Drop this Runtime-side wire precedence table when rebasing. This one-commit branch is 903 commits behind current main; a real rebase conflicts only in this model-adapter.ts file, and GitHub reports the current head as conflicting. Current main already resolves the effective wire once in resolveModelRuntime(...).wire and derives replay support from that runtime's reasoningReplay contract. Carrying this switch through the conflict would create a second authority that can drift from the model factory/wire resolver—the exact class of mismatch this PR is meant to fix. Keep the Core hosted-search correction and negative tests, remove this obsolete Runtime source/test half, then rerun affected Core/Runtime checks on the rebased exact head.

case 'openai-codex':
return true;
case 'github-copilot':
return runtime.apiProtocol === 'openai-responses';
case 'openai':
return (
(runtime.adapter.apiProtocol ??
runtime.apiProtocol ??
openAiAdapterApiProtocol(modelId, connection.providerType)) === 'openai-responses'
);
case 'openai-compatible':
return (
runtime.adapter.supportsOpenAiResponses === true &&
(runtime.apiProtocol ?? openAiAdapterApiProtocol(modelId, connection.providerType)) ===
'openai-responses'
);
default:
return false;
}
}

function fixedAnthropicThinkingBudget(
Expand Down
Loading