Skip to content
Draft
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
8 changes: 6 additions & 2 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,13 @@ Vercel AI SDK
Source: https://www.npmjs.com/package/@ai-sdk/provider-utils/v/5.0.11
https://www.npmjs.com/package/@ai-sdk/provider/v/4.0.3
Patched package: https://www.npmjs.com/package/@ai-sdk/provider-utils/v/5.0.28
https://www.npmjs.com/package/@ai-sdk/open-responses/v/2.0.34
Repository: https://github.com/vercel/ai/tree/main/packages/provider-utils
https://github.com/vercel/ai/tree/main/packages/provider
https://github.com/vercel/ai/tree/main/packages/open-responses
Adapted declarations: @ai-sdk/provider-utils 5.0.11, @ai-sdk/provider 4.0.3
Dependency patch: patches/@ai-sdk+provider-utils+5.0.28.patch
patches/@ai-sdk+open-responses+2.0.34.patch
Copyright 2023 Vercel, Inc.
License: Apache-2.0

Expand All @@ -223,8 +226,9 @@ License, Version 2.0 above applies to the adapted material.
The pinned versions are the ones present when the adaptation was made. The
adapted declarations are unchanged through @ai-sdk/provider-utils 5.0.25 and
@ai-sdk/provider 4.0.7, the versions recorded by the code origin audit. Maka
also redistributes a patch against @ai-sdk/provider-utils 5.0.28; that patch
modifies Apache-2.0 source and is covered by the license reproduced above.
also redistributes patches against @ai-sdk/provider-utils 5.0.28 and
@ai-sdk/open-responses 2.0.34; those patches modify Apache-2.0 source and are
covered by the license reproduced above.

Astryx

Expand Down
24 changes: 18 additions & 6 deletions docs/web-search-provider-capability.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@

# Provider-hosted web search capability

Status: implemented for OpenAI Responses and Anthropic Messages
`web_search_20250305`; other provider-native wires remain explicit follow-ups.
Status: implemented for OpenAI Responses, Alibaba Token Plan Open Responses,
and Anthropic Messages `web_search_20250305`; other provider-native wires
remain explicit follow-ups.
Verified against public provider documentation and shipped client/SDK behavior
on 2026-08-04.
through 2026-08-24.

## Problem

Expand Down Expand Up @@ -59,8 +60,15 @@ set `capabilities.webSearch`; otherwise narrow provider/model rules apply.
The implemented native adapters are:

- `openai-responses` for Codex-style `web_search`;
- `openai-responses` over the Alibaba Token Plan Open Responses profile for
`qwen3.8-max` Harness `web_search`;
- `anthropic-messages` for Claude Code-compatible `web_search_20250305`.

Alibaba Token Plan stays deliberately narrower than the provider's published
model matrix: the current Runtime routes only `qwen3.8-max` through its verified
Responses profile. Qwen 3.7 models remain on Chat in Maka and therefore do not
receive a provider-native search tool yet.

## Execution surfaces

All production `AiSdkBackend` composition roots use the same
Expand Down Expand Up @@ -105,8 +113,11 @@ turn-start tool surface
The search request uses the existing model credential. It does not duplicate the
secret into web-search settings, start a nested model request, or expose the
secret to the renderer. Provider search call/results are marked
`providerExecuted`, preserved separately from local ToolRuntime execution, and
replayed through the native provider-tool shape.
`providerExecuted` and preserved separately from local ToolRuntime execution.
Adapters that can round-trip the pair replay its native provider-tool shape.
Alibaba Open Responses currently keeps the durable episode and grounded text but
omits the pair from the next provider request, because that adapter cannot yet
round-trip provider-executed results without producing a dangling output.

The durable event keeps normalized `result` data for the canonical read model,
UI, and exports, plus opaque `providerOutput` data for provider-protocol replay.
Expand Down Expand Up @@ -187,7 +198,8 @@ search-heavy workflows that value source visibility over cache economics.
| OpenAI API | Responses `web_search` tool | Maka currently enables the native path for GPT-5 families, whose runtime wire is already Responses | Integrated through `openai-responses` |
| Custom Responses relay | Responses `web_search` tool when explicitly declared by model metadata | `openai-responses-compatible` connections with `apiProtocol=openai-responses` and `capabilities.webSearch=true` | Integrated through `openai-responses` |
| xAI API / OAuth | Responses Agent Tools `web_search` | Maka currently enables the verified Grok 4.5 Responses route | Integrated through `openai-responses` |
| Alibaba Model Studio | Responses `web_search` | Qwen 3.5 Plus/Flash provider support is recorded | Provider supports it; Maka Responses adapter pending |
| Alibaba Token Plan | Responses Harness `web_search` | `qwen3.8-max` on the Token Plan China/Singapore access paths | Integrated through the Alibaba Open Responses profile; live smoke pending |
| Alibaba Model Studio pay-as-you-go | Responses `web_search` | Provider model and deployment support varies | Provider supports it; Maka pay-as-you-go Responses adapter pending |
| Anthropic / Claude subscription | Messages `web_search_20250305` | Current Claude Opus/Sonnet/Haiku/Fable families | Integrated through `anthropic-messages` |
| MiniMax API / Coding Plan | Anthropic-compatible `web_search_20250305` | MiniMax M2.7/M3 families | Integrated through `anthropic-messages`; live provider verification pending |
| Google Gemini | Gemini API grounding with Google Search | Supported Gemini 2.0+ model families vary by release | Provider supports it; Maka adapter pending |
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/__tests__/model-web-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ describe('hosted web search capability', () => {
adapter: 'openai-responses',
implemented: false,
});
for (const providerType of ['alibaba-token-plan-cn', 'alibaba-token-plan'] as const) {
assert.deepEqual(resolveHostedWebSearchCapability(providerType, undefined, 'qwen3.8-max'), {
adapter: 'openai-responses',
implemented: true,
});
assert.equal(resolveHostedWebSearchCapability(providerType, undefined, 'qwen3.7-max'), null);
assert.equal(resolveHostedWebSearchCapability(providerType, undefined, 'qwen3.7-plus'), null);
assert.equal(
resolveHostedWebSearchCapability(providerType, undefined, 'qwen3.8-max-preview'),
null,
);
assert.equal(resolveHostedWebSearchCapability(providerType, undefined, 'qwen3.6-plus'), null);
}
assert.equal(resolveHostedWebSearchCapability('openai', undefined, 'gpt-4.1'), null);
});

Expand Down Expand Up @@ -155,6 +168,14 @@ describe('hosted web search capability', () => {
),
null,
);
assert.equal(
resolveHostedWebSearchCapability(
'alibaba-token-plan-cn',
[{ id: 'qwen3.8-max', apiProtocol: 'openai-chat' }],
'qwen3.8-max',
),
null,
);
});

it('publishes native search for both first-party DeepSeek V4 models', () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/model-web-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ function providerHostedWebSearchAdapter(
case 'alibaba':
case 'alibaba-cn':
return { adapter: 'openai-responses', implemented: false };
case 'alibaba-token-plan-cn':
case 'alibaba-token-plan':
return { adapter: 'openai-responses', implemented: true };
case 'anthropic':
case 'MiniMax':
case 'MiniMax-cn':
Expand Down Expand Up @@ -127,6 +130,11 @@ function providerDefaultHostedWebSearchCapability(
case 'alibaba':
case 'alibaba-cn':
return /^qwen3\.5-(?:plus|flash)(?:[.-]|$)/i.test(modelId) ? capability : null;
case 'alibaba-token-plan-cn':
case 'alibaba-token-plan':
// Keep the hosted tool on the exact model that the Token Plan runtime
// routes through Responses. Aliases and Qwen 3.7 still use Chat here.
return modelId === 'qwen3.8-max' ? capability : null;
case 'anthropic':
return /^claude-(?:[\d.]+-)*(?:opus|sonnet|haiku|fable)\b/i.test(modelId) ? capability : null;
case 'MiniMax':
Expand Down
22 changes: 22 additions & 0 deletions packages/runtime/src/__tests__/native-web-search-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,28 @@ test('turn-start routing compiles Claude models to the CC-compatible Anthropic t
});
});

test('turn-start routing compiles Qwen3.8 Max Token Plan to Responses web search', () => {
const routed = routeWebSearchTools({
tools: [],
settings: { enabled: true, defaultProvider: 'model' },
connection: {
slug: 'alibaba-token-plan-cn',
providerType: 'alibaba-token-plan-cn',
defaultModel: 'qwen3.8-max',
},
model: 'qwen3.8-max',
tavilyReady: false,
allowAddNative: true,
});

assert.equal(routed.length, 1);
assert.equal(routed[0]?.name, NATIVE_WEB_SEARCH_TOOL_NAME);
assert.deepEqual(routed[0]?.providerTool, {
kind: 'openai-web-search',
searchContextSize: 'medium',
});
});

test('root surfaces do not advertise unsupported DeepSeek native search', () => {
const connection = {
slug: 'deepseek',
Expand Down
199 changes: 199 additions & 0 deletions packages/runtime/src/__tests__/responses-wire-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { streamText, type ToolSet } from 'ai';
import type { LlmConnection } from '@maka/core/llm-connections';
import type { RuntimeEvent } from '@maka/core/runtime-event';
import { z } from 'zod';
Expand Down Expand Up @@ -542,6 +543,204 @@ describe('responses wire request body', () => {
assert.equal(headers?.get('x-token-plan-routing'), 'custom');
});

test('Alibaba Token Plan lowers and decodes provider-executed web search', async () => {
let body: Record<string, unknown> | undefined;
const webSearchCall = {
type: 'web_search_call',
id: 'web-search-1',
status: 'completed',
action: {
type: 'search',
query: 'latest Maka',
sources: [{ type: 'url', url: 'https://maka.example/releases' }],
},
};
const message = {
type: 'message',
id: 'message-1',
status: 'completed',
role: 'assistant',
content: [{ type: 'output_text', text: 'Maka shipped.', annotations: [] }],
};
const completed = {
id: 'response-web-search',
object: 'response',
created_at: 1,
model: 'qwen3.8-max',
status: 'completed',
output: [webSearchCall, message],
usage: {
input_tokens: 10,
output_tokens: 4,
total_tokens: 14,
x_tools: { web_search: { count: 1 } },
},
};
const events = [
{ type: 'response.created', response: { id: completed.id } },
{
type: 'response.output_item.added',
output_index: 0,
item: { ...webSearchCall, status: 'in_progress', action: undefined },
},
{ type: 'response.output_item.done', output_index: 0, item: webSearchCall },
{ type: 'response.output_item.added', output_index: 1, item: message },
{
type: 'response.output_text.delta',
item_id: message.id,
output_index: 1,
content_index: 0,
delta: 'Maka shipped.',
},
{ type: 'response.output_item.done', output_index: 1, item: message },
{ type: 'response.completed', response: completed },
];
const fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
body = JSON.parse(String(init?.body));
return new Response(
`${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\ndata: [DONE]\n\n`,
{ status: 200, headers: { 'content-type': 'text/event-stream' } },
);
}) as unknown as typeof globalThis.fetch;
const connection = {
...conn('alibaba-token-plan-cn'),
baseUrl: 'https://token-plan.example/compatible-mode/v1',
};
const model = getAIModel({
connection,
apiKey: 'token-plan-key',
modelId: 'qwen3.8-max',
fetch,
});
const tools = lowerModelTools({
WebSearch: {
kind: 'provider',
providerTool: { kind: 'openai-web-search', searchContextSize: 'medium' },
},
});
const result = streamText({
model,
messages: [{ role: 'user', content: 'What shipped?' }],
tools: tools as ToolSet,
maxRetries: 0,
});
const parts = [];
for await (const part of result.fullStream) parts.push(part);

assert.deepEqual(body?.tools, [{ type: 'web_search' }]);
assert.equal(body?.store, false);
const call = parts.find((part) => part.type === 'tool-call');
assert.ok(call && call.type === 'tool-call');
assert.equal(call.toolCallId, 'web-search-1');
assert.equal(call.toolName, 'WebSearch');
assert.equal(call.providerExecuted, true);
const toolResult = parts.find((part) => part.type === 'tool-result');
assert.ok(toolResult && toolResult.type === 'tool-result');
assert.equal(toolResult.providerExecuted, true);
assert.deepEqual(toolResult.output, {
action: { type: 'search', query: 'latest Maka' },
sources: [{ type: 'url', url: 'https://maka.example/releases' }],
});
assert.equal(await result.text, 'Maka shipped.');
const finishStep = parts.find((part) => part.type === 'finish-step');
assert.ok(finishStep && finishStep.type === 'finish-step');
assert.deepEqual(finishStep.usage.raw?.x_tools, { web_search: { count: 1 } });
});

test('Open Responses preserves search, open-page, and find-in-page actions', async () => {
let body: Record<string, unknown> | undefined;
const calls = [
{
type: 'web_search_call',
id: 'search-1',
status: 'completed',
action: { type: 'search', queries: ['latest Maka'] },
},
{
type: 'web_search_call',
id: 'open-1',
status: 'completed',
action: { type: 'open_page', url: 'https://maka.example/releases' },
},
{
type: 'web_search_call',
id: 'find-1',
status: 'completed',
action: {
type: 'find_in_page',
url: 'https://maka.example/releases',
pattern: 'WebSearch',
},
},
];
const completed = {
id: 'response-page-actions',
object: 'response',
created_at: 1,
model: 'deepseek-v4-flash',
status: 'completed',
output: calls,
usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 },
};
const events = [
{ type: 'response.created', response: { id: completed.id } },
...calls.flatMap((call, output_index) => [
{
type: 'response.output_item.added',
output_index,
item: { ...call, status: 'in_progress', action: undefined },
},
{ type: 'response.output_item.done', output_index, item: call },
]),
{ type: 'response.completed', response: completed },
];
const fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
body = JSON.parse(String(init?.body));
return new Response(
`${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\ndata: [DONE]\n\n`,
{ status: 200, headers: { 'content-type': 'text/event-stream' } },
);
}) as unknown as typeof globalThis.fetch;
const model = getAIModel({
connection: { ...conn('deepseek'), baseUrl: 'https://deepseek.example' },
apiKey: 'deepseek-key',
modelId: 'deepseek-v4-flash',
fetch,
});
const tools = lowerModelTools({
WebSearch: {
kind: 'provider',
providerTool: { kind: 'openai-web-search', searchContextSize: 'medium' },
},
});
const result = streamText({
model,
messages: [{ role: 'user', content: 'Search, open, and find.' }],
tools: tools as ToolSet,
maxRetries: 0,
});
const parts = [];
for await (const part of result.fullStream) parts.push(part);

assert.deepEqual(body?.tools, [{ type: 'web_search' }]);
assert.deepEqual(
parts
.filter((part) => part.type === 'tool-result')
.map((part) => (part.type === 'tool-result' ? part.output : undefined)),
[
{ action: { type: 'search', queries: ['latest Maka'] } },
{ action: { type: 'openPage', url: 'https://maka.example/releases' } },
{
action: {
type: 'findInPage',
url: 'https://maka.example/releases',
pattern: 'WebSearch',
},
},
],
);
});

test('adds the V2 trigger only through the explicit OpenAI provider option', async () => {
const bodies: Array<Record<string, unknown>> = [];
const fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
Expand Down
Loading