diff --git a/doc/pi_multiprovider_integration.md b/doc/pi_multiprovider_integration.md index 7978575..6447deb 100644 --- a/doc/pi_multiprovider_integration.md +++ b/doc/pi_multiprovider_integration.md @@ -12,9 +12,13 @@ smoke 覆盖;34 来源与 Proxy 路由的最终全仓测试、lint、声明构 ## 依赖版本与 ESM 加载 -当前使用 `@earendil-works/pi-ai@1.0.0` 和 +当前使用 `@earendil-works/pi-ai@1.3.0` 和 `@google/genai@2.21.0`。Pi 的间接依赖沿用其发布版本声明(包括 Anthropic SDK -0.124.0 和 Google SDK 2.21.0),不强行替换上游依赖。 +0.129.0 和 Google SDK 2.21.0),不强行替换上游依赖。 + +Pi 1.3.0 目录包含 Google / OpenCode Zen 的 `gemini-4-argon`,以及 OpenCode Go 的 +`gemini-4-argon-contributor`。Google 代理适配器复用上游的思考等级转换,发送 +`thinkingLevel`;Argon 不支持关闭思考,关闭或最低等级请求会按目录约束回落到 `LOW`。 Pi 1.x 在 provider 边界将 `Context.systemPrompt` 和 `Context.tools` 规范化为 transcript 中的系统消息。Google 自定义传输适配器从该消息序列读取提示词和工具;直接调用 adapter 的测试也先调用 @@ -62,7 +66,7 @@ Codex。 | Google Gemini | `google-generative-ai` | `https://generativelanguage.googleapis.com/v1beta` | | Groq | `openai-completions` | `https://api.groq.com/openai/v1` | | Hugging Face | `openai-completions` | `https://router.huggingface.co/v1` | -| Kimi For Coding | `anthropic-messages` | `https://api.kimi.com/coding` | +| Kimi For Coding | `openai-completions` | `https://api.kimi.com/coding/v1` | | MiniMax | `anthropic-messages` | `https://api.minimax.io/anthropic` | | MiniMax(中国) | `anthropic-messages` | `https://api.minimaxi.com/anthropic` | | Mistral | `mistral-conversations` | `https://api.mistral.ai` | @@ -71,8 +75,8 @@ Codex。 | NVIDIA | `openai-completions` | `https://integrate.api.nvidia.com/v1` | | OpenAI | `openai-responses`、`openai-completions` | `https://api.openai.com/v1` | | OpenAI Codex | `openai-codex-responses` | `https://chatgpt.com/backend-api` | -| OpenCode Zen | `anthropic-messages`、`google-generative-ai`、`openai-completions`、`openai-responses` | Messages:`https://opencode.ai/zen`;其他 API:`https://opencode.ai/zen/v1` | -| OpenCode Go | `anthropic-messages`、`openai-completions`、`openai-responses` | Messages:`https://opencode.ai/zen/go`;其他 API:`https://opencode.ai/zen/go/v1` | +| OpenCode Zen | `anthropic-messages`、`google-generative-ai`、`openai-completions`、`openai-responses` | Messages:`https://opencode.ai/inference`;其他 API:`https://opencode.ai/inference/v1` | +| OpenCode Go | `anthropic-messages`、`google-generative-ai`、`openai-completions`、`openai-responses` | Messages:`https://opencode.ai/inference/go`;其他 API:`https://opencode.ai/inference/go/v1` | | OpenRouter | `openai-completions`、`anthropic-messages` | Completions:`https://openrouter.ai/api/v1`;Messages:`https://openrouter.ai/api` | | Qwen Token Plan | `openai-completions` | `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | | Qwen Token Plan(中国) | `openai-completions` | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | @@ -443,6 +447,7 @@ SSE 在流结束后合并输出,中断或读取失败时保留已收到的正 可按场景 ID 子串筛选,多个子串用逗号分隔。密钥仅在实际请求的 fetch 边界注入,界面使用占位值;报告对错误信息脱敏,写入已忽略的 `coverage/pi-st-final/configured-cases.json`。本集合只通过上述显式命令启动,默认 `yarn test` / `yarn test:coverage` 排除 `tests/live/`,不会执行它;凭据文件及测试报告不得提交。 + 单项复测保留该场景在完整矩阵中的预期变量值,便于对照失败结果。 - `update:pi:st-prompt-fixtures` 在隔离真实浏览器中同时捕获 Legacy 与 Pi 的当前预设、其他预设、内置破限三条 prompt 路径,并生成带版本/产物 provenance 的回归 fixtures。三路分别为 12/11/15 条 messages,逐路 JSON 完全一致且没有允许差异或 normalization;覆盖宏、prompt-only 正则、角色卡、世界书过滤与深度、历史裁剪、注入和 `filterPrompts`。这三条“聊天消息”fixtures 均未出现历史工具消息,因此首版不额外扩大该回归范围。 @@ -475,11 +480,13 @@ SSE 在流结束后合并输出,中断或读取失败时保留已收到的正 429 会立即停止后续请求。本次受控重试中 Responses、Anthropic Messages 和 Chat Completions 三条均成功返回预期响应,且路径、signal、认证头和 CORS 检查全部通过。 - `yarn test:pi:st-server-cancel` 让真实 Firefox 中的 Pi - Responses 请求和主聊天请求同时连接到本机跨端口流式 HTTP 服务。停止 Pi 后,服务端观察到对应响应在 - `finish` 前关闭,而主聊天流与 signal 仍保持活跃;只有再点击真实 `#mes_stop` - 后主聊天流才关闭。该测试证明浏览器到可观测服务端的取消传播及两条 stop 路径隔离,不代表任何真实 Provider 已停止生成或计费。 + Responses 请求和通过 `ChatCompletionService` 发起的独立主传输同时连接到本机跨端口流式 HTTP 服务。 + 按 generation ID 停止 Pi 后,服务端观察到对应响应在 `finish` 前关闭,而主传输仍保持活跃; + 再中止主传输自己的 `AbortController`,确认其连接关闭。原生发送/停止按钮生命周期由 `test:pi:st-features` + 验证。该测试证明浏览器到可观测服务端的取消传播及两条 stop 路径隔离,不代表任何真实 Provider 已停止生成或计费。 - `yarn test:pi:st-live` 使用临时 ST/Firefox - profile 和同一份内存凭据尝试真实浏览器请求,并在结束前清空页面内存、扫描临时数据后再删除目录。OpenAI + profile 和同一份内存凭据尝试真实浏览器请求,默认模型为 `openai/gpt-4.1-mini`, + 可通过 `MVU_PI_ST_LIVE_MODEL` 覆盖;结束前清空页面内存、扫描临时数据后再删除目录。OpenAI Responses 与 OpenAI Chat Completions 已分别通过:请求到达 `/api/v1/responses` 和 `/api/v1/chat/completions`,HTTP 200、CORS、Authorization、真实且未预先中止的 `AbortSignal`、最终响应 marker、变量更新标签和 analysis 清理均符合预期。OpenRouter-compatible diff --git a/package.json b/package.json index bc5a4d7..1b0d2ed 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ ], "private": true, "dependencies": { - "@earendil-works/pi-ai": "1.0.0", + "@earendil-works/pi-ai": "1.3.0", "@google/genai": "2.21.0", "compare-versions": "^6.1.1", "corepack": "^0.32.0", diff --git a/src/function/update/model_list.ts b/src/function/update/model_list.ts index 6622b33..75a2260 100644 --- a/src/function/update/model_list.ts +++ b/src/function/update/model_list.ts @@ -95,8 +95,8 @@ const OPENAI_MODEL_DISCOVERY: Readonly< 'X-GitHub-Api-Version': '2026-06-01', }), }), - opencode: Object.freeze({ baseUrl: 'https://opencode.ai/zen/v1' }), - 'opencode-go': Object.freeze({ baseUrl: 'https://opencode.ai/zen/go/v1' }), + opencode: Object.freeze({ baseUrl: 'https://opencode.ai/inference/v1' }), + 'opencode-go': Object.freeze({ baseUrl: 'https://opencode.ai/inference/go/v1' }), 'vercel-ai-gateway': Object.freeze({ baseUrl: 'https://ai-gateway.vercel.sh/v1' }), }); diff --git a/src/function/update/pi/google_proxy_adapter.ts b/src/function/update/pi/google_proxy_adapter.ts index 84d05d7..44b7b40 100644 --- a/src/function/update/pi/google_proxy_adapter.ts +++ b/src/function/update/pi/google_proxy_adapter.ts @@ -15,6 +15,7 @@ import { createAssistantMessageEventStream, getCurrentSystemPrompt, getCurrentTools, + getDisabledGoogleThinkingConfig, isGoogleThinkingPart, mapGoogleStopReason, resolveGoogleFunctionCallingMode, @@ -22,6 +23,8 @@ import { retainGoogleThoughtSignature, retryGoogleRequest, supportsGoogleStrictToolSampling, + toGoogleThinkingLevel, + usesGoogleThinkingLevel, type Api, type AssistantMessage, type AssistantMessageEventStream, @@ -279,7 +282,7 @@ function buildParams( } config.thinkingConfig = thinking_config; } else if (model.reasoning && options.thinking && !options.thinking.enabled) { - config.thinkingConfig = getDisabledThinkingConfig(model); + config.thinkingConfig = getDisabledGoogleThinkingConfig(model); } if (options.signal) { @@ -562,60 +565,6 @@ function streamWithInjectedFetch( return stream; } -/** 识别使用思考等级配置的 Gemma 4 模型名称。 */ -function isGemma4Model(model: Model<'google-generative-ai'>): boolean { - return /gemma-?4/.test(model.id.toLowerCase()); -} - -/** 识别 Gemini 3 Pro 系列,以使用其支持的思考等级范围。 */ -function isGemini3ProModel(model: Model<'google-generative-ai'>): boolean { - return /gemini-3(?:\.\d+)?-pro/.test(model.id.toLowerCase()); -} - -/** 识别 Gemini 3 Flash 系列及对应 latest 别名。 */ -function isGemini3FlashModel(model: Model<'google-generative-ai'>): boolean { - const id = model.id.toLowerCase(); - return ( - /gemini-3(?:\.\d+)?-flash/.test(id) || - id === 'gemini-flash-latest' || - id === 'gemini-flash-lite-latest' - ); -} - -/** 为不同 Google 模型选择关闭或最低允许思考配置,避免发送不支持的零预算。 */ -function getDisabledThinkingConfig(model: Model<'google-generative-ai'>): ThinkingConfig { - if (isGemini3ProModel(model)) { - return { thinkingLevel: 'LOW' as ThinkingConfig['thinkingLevel'] }; - } - if (isGemini3FlashModel(model) || isGemma4Model(model)) { - return { thinkingLevel: 'MINIMAL' as ThinkingConfig['thinkingLevel'] }; - } - return { thinkingBudget: 0 }; -} - -/** 把 Pi 思考等级映射到当前 Google 模型实际支持的等级。 */ -function getThinkingLevel( - effort: ResolvedGoogleThinkingLevel, - model: Model<'google-generative-ai'> -): GoogleApiThinkingLevel { - if (isGemini3ProModel(model)) { - return effort === 'minimal' || effort === 'low' ? 'LOW' : 'HIGH'; - } - if (isGemma4Model(model)) { - return effort === 'minimal' || effort === 'low' ? 'MINIMAL' : 'HIGH'; - } - switch (effort) { - case 'minimal': - return 'MINIMAL'; - case 'low': - return 'LOW'; - case 'medium': - return 'MEDIUM'; - case 'high': - return 'HIGH'; - } -} - /** 优先使用自定义思考预算,否则采用相应模型的预算映射;未知模型交由服务端动态分配。 */ function getGoogleBudget( model: Model<'google-generative-ai'>, @@ -671,13 +620,13 @@ function streamSimpleWithInjectedFetch( ); } const resolved_level = resolveGoogleThinkingLevel(model, clamped_reasoning); - if (isGemini3ProModel(model) || isGemini3FlashModel(model) || isGemma4Model(model)) { + if (usesGoogleThinkingLevel(model)) { return streamWithInjectedFetch( model, context, { ...base, - thinking: { enabled: true, level: getThinkingLevel(resolved_level, model) }, + thinking: { enabled: true, level: toGoogleThinkingLevel(resolved_level) }, }, fetch_impl ); diff --git a/src/function/update/pi/pi_gateway.ts b/src/function/update/pi/pi_gateway.ts index 4f84716..737f837 100644 --- a/src/function/update/pi/pi_gateway.ts +++ b/src/function/update/pi/pi_gateway.ts @@ -109,6 +109,7 @@ export { openAICodexResponsesApi } from '@earendil-works/pi-ai/api/openai-codex- export { convertMessages as convertGoogleMessages, convertTools as convertGoogleTools, + getDisabledGoogleThinkingConfig, isThinkingPart as isGoogleThinkingPart, // 本地 Google SDK 可能先于 Pi 固定的 SDK 版本增加枚举值,停止原因仍沿用 Pi 的映射。 mapStopReasonString as mapGoogleStopReason, @@ -117,6 +118,8 @@ export { retainThoughtSignature as retainGoogleThoughtSignature, retryGoogleRequest, supportsGoogleStrictToolSampling, + toGoogleThinkingLevel, + usesGoogleThinkingLevel, } from '@earendil-works/pi-ai/api/google-shared'; export type { GoogleApiThinkingLevel, diff --git a/src/function/update/pi/provider_target.ts b/src/function/update/pi/provider_target.ts index 5ac0ce6..a12c67b 100644 --- a/src/function/update/pi/provider_target.ts +++ b/src/function/update/pi/provider_target.ts @@ -215,12 +215,12 @@ export const PI_PROVIDER_TARGET_REGISTRY: Readonly< 'kimi-coding': definePiProviderTarget({ key: 'kimi-coding', providerId: 'kimi-coding', - defaultApi: 'anthropic-messages', - allowedApis: Object.freeze(['anthropic-messages'] as const), + defaultApi: 'openai-completions', + allowedApis: Object.freeze(['openai-completions'] as const), defaultAuthType: 'api_key', allowedAuthTypes: Object.freeze(['api_key'] as const), - defaultBaseUrl: 'https://api.kimi.com/coding', - corsProxyRequiredApis: ['anthropic-messages'], + defaultBaseUrl: 'https://api.kimi.com/coding/v1', + corsProxyRequiredApis: ['openai-completions'], allowCustomEndpoint: false, }), minimax: definePiProviderTarget({ @@ -318,12 +318,12 @@ export const PI_PROVIDER_TARGET_REGISTRY: Readonly< ] as const), defaultAuthType: 'api_key', allowedAuthTypes: Object.freeze(['api_key'] as const), - defaultBaseUrl: 'https://opencode.ai/zen', + defaultBaseUrl: 'https://opencode.ai/inference', apiBaseUrls: Object.freeze({ - 'anthropic-messages': 'https://opencode.ai/zen', - 'google-generative-ai': 'https://opencode.ai/zen/v1', - 'openai-completions': 'https://opencode.ai/zen/v1', - 'openai-responses': 'https://opencode.ai/zen/v1', + 'anthropic-messages': 'https://opencode.ai/inference', + 'google-generative-ai': 'https://opencode.ai/inference/v1', + 'openai-completions': 'https://opencode.ai/inference/v1', + 'openai-responses': 'https://opencode.ai/inference/v1', }), corsProxyRequiredApis: [ 'anthropic-messages', @@ -339,18 +339,25 @@ export const PI_PROVIDER_TARGET_REGISTRY: Readonly< defaultApi: 'anthropic-messages', allowedApis: Object.freeze([ 'anthropic-messages', + 'google-generative-ai', 'openai-completions', 'openai-responses', ] as const), defaultAuthType: 'api_key', allowedAuthTypes: Object.freeze(['api_key'] as const), - defaultBaseUrl: 'https://opencode.ai/zen/go', + defaultBaseUrl: 'https://opencode.ai/inference/go', apiBaseUrls: Object.freeze({ - 'anthropic-messages': 'https://opencode.ai/zen/go', - 'openai-completions': 'https://opencode.ai/zen/go/v1', - 'openai-responses': 'https://opencode.ai/zen/go/v1', + 'anthropic-messages': 'https://opencode.ai/inference/go', + 'google-generative-ai': 'https://opencode.ai/inference/go/v1', + 'openai-completions': 'https://opencode.ai/inference/go/v1', + 'openai-responses': 'https://opencode.ai/inference/go/v1', }), - corsProxyRequiredApis: ['anthropic-messages', 'openai-completions', 'openai-responses'], + corsProxyRequiredApis: [ + 'anthropic-messages', + 'google-generative-ai', + 'openai-completions', + 'openai-responses', + ], allowCustomEndpoint: false, }), openrouter: definePiProviderTarget({ diff --git a/src/panel/open_source_licenses.ts b/src/panel/open_source_licenses.ts index e1c5ab7..c1b3b29 100644 --- a/src/panel/open_source_licenses.ts +++ b/src/panel/open_source_licenses.ts @@ -16,14 +16,14 @@ export type OpenSourceLicense = Readonly<{ export const OPEN_SOURCE_LICENSES = [ { packageName: '@anthropic-ai/sdk', - version: '0.124.0', + version: '0.129.0', license: 'MIT', projectUrl: 'https://github.com/anthropics/anthropic-sdk-typescript', }, { packageName: '@earendil-works/pi-ai', displayName: 'Earendil Works AI', - version: '1.0.0', + version: '1.3.0', license: 'MIT', projectUrl: 'https://github.com/earendil-works/pi', }, diff --git a/tests/live/pi_st_configured_live_smoke.mjs b/tests/live/pi_st_configured_live_smoke.mjs index 8d3f458..8a0ad9d 100644 --- a/tests/live/pi_st_configured_live_smoke.mjs +++ b/tests/live/pi_st_configured_live_smoke.mjs @@ -434,6 +434,8 @@ export async function runPiStConfiguredLiveSmoke(options) { : []), ]; }) + // 先分配预期值再筛选,单项复测与完整矩阵使用相同输入。 + .map((scenario, index) => ({ ...scenario, value: 7 + index })) .filter(scenario => { const filters = (process.env.MVU_PI_ST_CONFIGURED_CASE || '') .split(',') @@ -454,8 +456,7 @@ export async function runPiStConfiguredLiveSmoke(options) { }, }; try { - for (const [index, scenario] of scenarios.entries()) { - const testCase = { ...scenario, value: 7 + index }; + for (const testCase of scenarios) { process.stderr.write(`[pi-st-live] ${testCase.id}\n`); const result = { id: testCase.id, diff --git a/tests/live/pi_st_oauth_smoke.mjs b/tests/live/pi_st_oauth_smoke.mjs index 8d563ab..2878c59 100644 --- a/tests/live/pi_st_oauth_smoke.mjs +++ b/tests/live/pi_st_oauth_smoke.mjs @@ -97,33 +97,25 @@ async function configureAnthropicOAuth(webDriver, scriptName) { [...candidate.options].some(option => option.value === '更多') )); choose(root, ['与插头相同', '自定义', '更多'], '更多'); + const choice = JSON.stringify(['anthropic', 'anthropic-messages', 'oauth']); await poll(() => [...root.querySelectorAll('select')].some(candidate => - [...candidate.options].some(option => option.value === 'anthropic') + [...candidate.options].some(option => option.value === choice) )); - choose(root, ['openai', 'openai-codex', 'anthropic', 'google'], 'anthropic'); - await poll(() => [...root.querySelectorAll('select')].some(candidate => - [...candidate.options].some(option => option.value === 'oauth') - )); - choose(root, ['api_key', 'oauth'], 'oauth'); + choose(root, [choice], choice); const status = await poll(() => root.querySelector('.mvu-oauth-status > span')); const source = [...root.querySelectorAll('select')].find(candidate => [...candidate.options].some(option => option.value === '更多') ); - const provider = [...root.querySelectorAll('select')].find(candidate => - [...candidate.options].some(option => option.value === 'anthropic') - ); - const auth = [...root.querySelectorAll('select')].find(candidate => - [...candidate.options].some(option => option.value === 'oauth') - ); - const api = [...root.querySelectorAll('select')].find(candidate => - [...candidate.options].some(option => option.value === 'anthropic-messages') + const selected = [...root.querySelectorAll('select')].find(candidate => + candidate.value === choice ); + const [provider, api, auth] = JSON.parse(selected?.value || '[]'); done({ ok: true, sourceMore: source?.value === '更多', - providerAnthropic: provider?.value === 'anthropic', - apiAnthropicMessages: api?.value === 'anthropic-messages', - authOAuth: auth?.value === 'oauth', + providerAnthropic: provider === 'anthropic', + apiAnthropicMessages: api === 'anthropic-messages', + authOAuth: auth === 'oauth', oauthVisible: !!status, initiallySignedOut: /not signed in|未登录/i.test(status?.textContent || ''), apiKeyHidden: root.querySelectorAll('input[type="password"]').length === 0, @@ -503,7 +495,7 @@ async function cancelAttempt(webDriver) { popupClosedByHarness: probe.popups.slice(probe.popupCheckpoint) .every(popup => popup.closed), tokenFetchCount: probe.tokenFetchCount, - credentialAbsent: !config?.pi?.credentials?.anthropic, + credentialAbsent: !config?.pi?.credentials?.[config?.pi?.credentialIds?.anthropic ?? 'anthropic'], }); return; } @@ -639,7 +631,7 @@ async function switchSourceClearsAttempt(webDriver) { popupClosedByHarness: probe.popups.slice(probe.popupCheckpoint) .every(popup => popup.closed), tokenFetchCount: probe.tokenFetchCount, - credentialAbsent: !config?.pi?.credentials?.anthropic, + credentialAbsent: !config?.pi?.credentials?.[config?.pi?.credentialIds?.anthropic ?? 'anthropic'], }); return; } @@ -716,7 +708,7 @@ async function completeValidLoopback(webDriver) { if (signedIn && signOut) { const config = state.iframe.contentWindow.SillyTavern?.extensionSettings ?.mvu_settings?.['额外模型解析配置']; - const credential = config?.pi?.credentials?.anthropic; + const credential = config?.pi?.credentials?.[config?.pi?.credentialIds?.anthropic ?? 'anthropic']; const displayed = [...document.querySelectorAll('input, textarea')] .some(input => [probe.accessValue, probe.refreshValue] .some(secret => String(input.value || '').includes(secret))) || @@ -725,7 +717,7 @@ async function completeValidLoopback(webDriver) { const profiles = JSON.stringify(config?.['api方案列表'] || []); const credentialsOutsideAllowedSlot = JSON.stringify(Object.fromEntries( Object.entries(config?.pi?.credentials || {}) - .filter(([provider]) => provider !== 'anthropic') + .filter(([id]) => id !== (config?.pi?.credentialIds?.anthropic ?? 'anthropic')) )); const disallowedSettings = JSON.stringify({ apiKeys: config?.pi?.apiKeys || {}, @@ -830,7 +822,7 @@ async function readReloadedStatus(webDriver, scriptName, credential, expectedSig const status = oauth.querySelector('.mvu-oauth-status > span')?.textContent || ''; const config = iframe.contentWindow.SillyTavern?.extensionSettings ?.mvu_settings?.['额外模型解析配置']; - const credential = config?.pi?.credentials?.anthropic; + const credential = config?.pi?.credentials?.[config?.pi?.credentialIds?.anthropic ?? 'anthropic']; const displayed = [...document.querySelectorAll('input, textarea')] .some(input => [accessValue, refreshValue] .some(secret => String(input.value || '').includes(secret))) || @@ -838,7 +830,7 @@ async function readReloadedStatus(webDriver, scriptName, credential, expectedSig .some(secret => (document.body.textContent || '').includes(secret)); const credentialsOutsideAllowedSlot = JSON.stringify(Object.fromEntries( Object.entries(config?.pi?.credentials || {}) - .filter(([provider]) => provider !== 'anthropic') + .filter(([id]) => id !== (config?.pi?.credentialIds?.anthropic ?? 'anthropic')) )); const disallowedSettings = JSON.stringify({ apiKeys: config?.pi?.apiKeys || {}, @@ -942,7 +934,7 @@ async function signOutThroughConfirmation(webDriver, scriptName) { ok: true, confirmationVisible: true, affirmativePresent: true, - confirmationMentionsCredential: /credential|凭据/i.test(dialog.textContent || ''), + confirmationMentionsCurrentAccount: /current configuration|当前配置/i.test(dialog.textContent || '') && /sign out|登出/i.test(dialog.textContent || ''), confirmationDoesNotContainCredential: !!probe && ![probe.accessValue, probe.refreshValue] @@ -983,7 +975,7 @@ async function signOutThroughConfirmation(webDriver, scriptName) { .some(input => /^(sign in|login|登录)$/i.test(input.value)); const config = iframe?.contentWindow?.SillyTavern?.extensionSettings ?.mvu_settings?.['额外模型解析配置']; - if (signedOut && login && !config?.pi?.credentials?.anthropic) { + if (signedOut && login && !config?.pi?.credentials?.[config?.pi?.credentialIds?.anthropic ?? 'anthropic']) { const resources = [ ...performance.getEntriesByType('resource'), ...iframe.contentWindow.performance.getEntriesByType('resource'), @@ -1284,7 +1276,7 @@ export async function runPiStOAuthSmoke({ { confirmationVisible: signedOut.confirmationVisible, affirmativePresent: signedOut.affirmativePresent, - confirmationMentionsCredential: signedOut.confirmationMentionsCredential, + confirmationMentionsCurrentAccount: signedOut.confirmationMentionsCurrentAccount, confirmationDoesNotContainCredential: signedOut.confirmationDoesNotContainCredential, confirmed: signedOut.confirmed, diff --git a/tests/live/pi_st_server_cancel_smoke.mjs b/tests/live/pi_st_server_cancel_smoke.mjs index 69afad7..2157581 100644 --- a/tests/live/pi_st_server_cancel_smoke.mjs +++ b/tests/live/pi_st_server_cancel_smoke.mjs @@ -1,5 +1,6 @@ /** * 测试场景:结合真实酒馆浏览器和本地回环流式服务器,验证停止 Pi 后网络连接确实关闭且主生成保持独立。 + * 主传输使用 ChatCompletionService 的独立 signal;原生发送/停止按钮生命周期由 feature smoke 验证。 * 请求使用本地测试密钥,收集服务端和浏览器两侧取消证据。 */ import http from 'node:http'; @@ -253,9 +254,8 @@ async function configurePi(webDriver, scriptName, endpoint) { try { choose(['与插头相同', '自定义', '更多'], '更多'); await tick(); - choose(['openai', 'openai-codex', 'anthropic', 'google'], 'openai'); - await tick(); - choose(['openai-responses', 'openai-completions'], 'openai-responses'); + const sourceChoice = JSON.stringify(['openai', 'openai-responses', 'api_key']); + choose([sourceChoice], sourceChoice); await tick(); const grid = candidateDocuments.flatMap(owner => [...owner.querySelectorAll('.mvu-field-grid')]).find(candidate => @@ -292,7 +292,7 @@ async function configurePi(webDriver, scriptName, endpoint) { async function installBrowserHarness(webDriver, scriptName, endpoint) { const result = await webDriver.execute( ` - const [scriptName, endpoint, capturePrefix, mainModel, userMarker] = arguments; + const [scriptName, endpoint, capturePrefix] = arguments; let stage = 'context'; try { const context = window.SillyTavern.getContext(); @@ -302,18 +302,19 @@ async function installBrowserHarness(webDriver, scriptName, endpoint) { ); if (!iframe?.contentWindow) return { ok: false, stage: 'iframe' }; const retryEvent = iframe.contentWindow.getButtonEvent('重试额外模型解析'); - const stopEvent = iframe.contentWindow.getButtonEvent('停止“更多”额外模型解析'); const retryHandlers = context.eventSource?.events?.[retryEvent]; - const stopHandlers = context.eventSource?.events?.[stopEvent]; - if (!retryHandlers?.length || !stopHandlers?.length) return { ok: false, stage: 'handlers' }; + if (!retryHandlers?.length || typeof iframe.contentWindow.stopGenerationById !== 'function') { + return { ok: false, stage: 'handlers' }; + } const state = { originalTopFetch: window.fetch, originalFrameFetch: iframe.contentWindow.fetch, frame: iframe.contentWindow, retryHandler: retryHandlers.at(-1), - stopHandler: stopHandlers.at(-1), + stopHandler: () => iframe.contentWindow.stopGenerationById(state.piGenerationId), captureRequests: 0, + captureReadyCount: 0, piFetches: 0, mainFetches: 0, unexpectedExternal: 0, @@ -321,9 +322,6 @@ async function installBrowserHarness(webDriver, scriptName, endpoint) { mainSignal: null, piSignalAbortObserved: false, mainSignalAbortObserved: false, - mainModel, - userMarker, - sendEvents: { messageSent: 0, generationStopped: 0 }, }; const abortError = realm => new realm.DOMException('Aborted', 'AbortError'); const jsonResponse = value => new Response(JSON.stringify(value), { @@ -386,18 +384,21 @@ async function installBrowserHarness(webDriver, scriptName, endpoint) { return state.originalFrameFetch.call(iframe.contentWindow, input, init); }; - const eventTypes = context.eventTypes; - state.messageSentListener = () => { state.sendEvents.messageSent += 1; }; - state.generationStoppedListener = () => { state.sendEvents.generationStopped += 1; }; - context.eventSource.on(eventTypes.MESSAGE_SENT, state.messageSentListener); - context.eventSource.on(eventTypes.GENERATION_STOPPED, state.generationStoppedListener); + state.readyEvent = helper.tavern_events?.CHAT_COMPLETION_SETTINGS_READY || 'chat_completion_settings_ready'; + state.readyListener = data => { + if (typeof data?.model === 'string' && data.model.startsWith(capturePrefix)) { + state.captureReadyCount += 1; + state.piGenerationId = decodeURIComponent(data.model.slice(capturePrefix.length)); + } + }; + context.eventSource.on(state.readyEvent, state.readyListener); window.__mvuServerCancelSmoke = state; return { ok: true, stage: 'done' }; } catch (error) { return { ok: false, stage, errorName: String(error?.name || 'Error') }; } `, - [scriptName, endpoint, CAPTURE_MODEL_PREFIX, MAIN_MODEL, USER_MARKER] + [scriptName, endpoint, CAPTURE_MODEL_PREFIX] ); assertServerCancel( result?.ok, @@ -431,12 +432,18 @@ async function startPiRequest(webDriver) { const deadline = Date.now() + 30000; const poll = () => { if (state?.piFetches === 1 && state.piSignal) { - return done({ reached: true, captureRequests: state.captureRequests }); + return done({ + reached: true, + captureRequests: state.captureRequests, + captureReadyCount: state.captureReadyCount, + piFetches: state.piFetches, + }); } if (Date.now() >= deadline) { return done({ reached: false, captureRequests: state?.captureRequests ?? -1, + captureReadyCount: state?.captureReadyCount ?? -1, piFetches: state?.piFetches ?? -1, }); } @@ -448,93 +455,45 @@ async function startPiRequest(webDriver) { 35_000 ); assertServerCancel( - reached?.reached && reached.captureRequests === 1, - `pi-provider-not-reached-${reached?.captureRequests ?? -1}-${reached?.piFetches ?? -1}` + reached?.reached && reached.captureReadyCount === 1, + `pi-provider-not-reached-${reached?.captureReadyCount ?? -1}-${reached?.piFetches ?? -1}` ); } -/** 启动主生成:通过实际发送链路建立独立请求,用于验证 Pi 停止的隔离性。 */ -async function startMainSend(webDriver) { +/** 使用酒馆的 ChatCompletionService 建立独立主传输,避免 Pi 活跃期间点击被禁用的发送按钮。 */ +async function startMainTransport(webDriver) { const result = await webDriver.executeAsync( ` const [mainModel, userMarker] = arguments; const done = arguments[arguments.length - 1]; - let stage = 'initialize'; (async () => { const state = window.__mvuServerCancelSmoke; const context = window.SillyTavern.getContext(); - const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); - const fire = (selector, value, eventName) => { - const element = document.querySelector(selector); - if (!element) throw new Error(selector); - if (element.type === 'checkbox') element.checked = value; - else element.value = value; - element.dispatchEvent(new Event(eventName, { bubbles: true })); - }; - if (document.body.dataset.generating === 'true') throw new Error('generation-not-idle'); - context.activateSendButtons(); - stage = 'configure-main'; - fire('#main_api', 'openai', 'change'); - fire('#custom_api_url_text', 'http://127.0.0.1/mvu-local-main', 'input'); - fire('#custom_model_id', mainModel, 'input'); - fire('#stream_toggle', false, 'change'); - fire('#chat_completion_source', 'custom', 'change'); - const statusBefore = state.statusRequests ?? 0; - document.querySelector('#api_button_openai')?.click(); - const onlineDeadline = Date.now() + 15000; - while ( - window.SillyTavern.getContext().onlineStatus === 'no_connection' && - Date.now() < onlineDeadline - ) await wait(100); - if (window.SillyTavern.getContext().onlineStatus === 'no_connection') { - throw new Error('main-offline-' + statusBefore); + state.mainController = new AbortController(); + state.mainInvocation = context.ChatCompletionService.sendRequest({ + stream: false, + messages: [{ role: 'user', content: userMarker }], + model: mainModel, + chat_completion_source: 'custom', + custom_url: 'http://127.0.0.1/mvu-local-main', + }, false, state.mainController.signal).then( + () => ({ settled: true, rejected: false }), + () => ({ settled: true, rejected: true }) + ); + const deadline = Date.now() + 30000; + while (state.mainFetches !== 1 && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 50)); } - - stage = 'send'; - const textarea = document.querySelector('#send_textarea'); - const sendButton = document.querySelector('#send_but'); - if (!textarea || !sendButton) throw new Error('send-controls'); - const chatLengthBefore = context.chat.length; - textarea.value = userMarker; - textarea.dispatchEvent(new Event('input', { bubbles: true })); - textarea.dispatchEvent(new Event('change', { bubbles: true })); - sendButton.click(); - const mainDeadline = Date.now() + 30000; - while (state.mainFetches !== 1 && Date.now() < mainDeadline) await wait(50); done({ reached: state.mainFetches === 1, - stage, - signalPresent: Boolean(state.mainSignal), - signalAborted: state.mainSignal?.aborted === true, - generating: document.body.dataset.generating === 'true', - chatDelta: context.chat.length - chatLengthBefore, - userMessageSeen: context.chat.some( - message => message?.is_user === true && message?.mes === userMarker - ), - messageSent: state.sendEvents.messageSent, + independentSignal: Boolean(state.mainSignal) && !state.mainSignal.aborted, }); - })().catch(error => done({ - reached: false, - stage, - errorName: String(error?.name || 'Error'), - errorCode: String(error?.message || 'unknown').slice(0, 100), - })); - `, + })().catch(() => done({ reached: false })); + `, [MAIN_MODEL, USER_MARKER], - 65_000 - ); - assertServerCancel( - result?.reached, - `main-not-reached-${result?.stage ?? 'wait'}-${result?.errorName ?? 'none'}-${result?.errorCode ?? 'none'}` - ); - assertServerCancel(result.signalPresent && !result.signalAborted, 'main-signal-initial'); - assertServerCancel( - result.generating && - result.chatDelta >= 1 && - result.userMessageSeen && - result.messageSent >= 1, - 'main-send-lifecycle' + 35000 ); + assertServerCancel(result?.reached && result.independentSignal, 'main-transport-not-reached'); return result; } @@ -542,9 +501,8 @@ async function startMainSend(webDriver) { async function stopPiAndObserveIsolation(webDriver) { const stopped = await webDriver.execute(` const state = window.__mvuServerCancelSmoke; - if (!state?.stopHandler) return false; - Promise.resolve().then(() => state.stopHandler()); - return true; + if (!state?.stopHandler || typeof state.piGenerationId !== 'string') return false; + return state.stopHandler(); `); assertServerCancel(stopped, 'pi-stop-not-dispatched'); } @@ -579,52 +537,24 @@ async function stopMain(webDriver) { ` const done = arguments[arguments.length - 1]; const state = window.__mvuServerCancelSmoke; - const stopButton = document.querySelector('#mes_stop'); - const visible = Boolean(stopButton) && getComputedStyle(stopButton).display !== 'none'; - const stoppedBefore = state.sendEvents.generationStopped; - stopButton?.click(); - const deadline = Date.now() + 10000; - const poll = () => { - if ( - state.mainSignal?.aborted === true && - document.body.dataset.generating !== 'true' - ) { - return done({ - stopButtonPresent: Boolean(stopButton), - stopButtonVisible: visible, - mainSignalAborted: true, - mainSignalAbortObserved: state.mainSignalAbortObserved, - generationIdle: true, - generationStopped: state.sendEvents.generationStopped > stoppedBefore, - mainFetches: state.mainFetches, - piFetches: state.piFetches, - }); - } - if (Date.now() >= deadline) { - return done({ - stopButtonPresent: Boolean(stopButton), - stopButtonVisible: visible, - mainSignalAborted: state.mainSignal?.aborted === true, - mainSignalAbortObserved: state.mainSignalAbortObserved, - generationIdle: document.body.dataset.generating !== 'true', - generationStopped: state.sendEvents.generationStopped > stoppedBefore, - mainFetches: state.mainFetches, - piFetches: state.piFetches, - }); - } - setTimeout(poll, 50); - }; - poll(); - `, + state.mainController.abort(); + Promise.race([ + state.mainInvocation, + new Promise(resolve => setTimeout(() => resolve({ settled: false }), 10000)), + ]).then(invocation => done({ + settled: invocation.settled, + mainSignalAborted: state.mainSignal?.aborted === true, + mainSignalAbortObserved: state.mainSignalAbortObserved, + piFetches: state.piFetches, + })); + `, [], - 15_000 + 15000 ); - assertServerCancel(result?.stopButtonPresent && result.stopButtonVisible, 'main-stop-button'); assertServerCancel( - result.mainSignalAborted && result.mainSignalAbortObserved && result.generationIdle, + result.settled && result.mainSignalAborted && result.mainSignalAbortObserved, 'main-stop-signal' ); - assertServerCancel(result.generationStopped, 'main-stop-event'); return result; } @@ -634,11 +564,7 @@ async function cleanupBrowserHarness(webDriver) { const state = window.__mvuServerCancelSmoke; if (!state) return { ok: false }; const context = window.SillyTavern.getContext(); - context.eventSource.removeListener(context.eventTypes.MESSAGE_SENT, state.messageSentListener); - context.eventSource.removeListener( - context.eventTypes.GENERATION_STOPPED, - state.generationStoppedListener - ); + context.eventSource.removeListener(state.readyEvent, state.readyListener); window.fetch = state.originalTopFetch; state.frame.fetch = state.originalFrameFetch; const result = { @@ -646,6 +572,7 @@ async function cleanupBrowserHarness(webDriver) { topFetchRestored: window.fetch === state.originalTopFetch, frameFetchRestored: state.frame.fetch === state.originalFrameFetch, captureRequests: state.captureRequests, + captureReadyCount: state.captureReadyCount, piFetches: state.piFetches, mainFetches: state.mainFetches, unexpectedExternal: state.unexpectedExternal, @@ -681,7 +608,7 @@ export async function runPiStServerCancelSmoke({ ); // 双流并行:主生成开始后,两条服务端响应都应保持打开。 - const mainSend = await startMainSend(webDriver); + const mainSend = await startMainTransport(webDriver); const bothStreaming = await waitForServer( harness, current => current.main.requests === 1 && current.main.responseHeadersSent, @@ -750,15 +677,11 @@ export async function runPiStServerCancelSmoke({ !afterMainStop.main.responseFinished, independentRequestCounts: afterMainStop.pi.requests === 1 && afterMainStop.main.requests === 1, - mainSendButtonLifecycle: - mainSend.generating && mainSend.userMessageSeen && mainSend.messageSent >= 1, - mainStopButtonLifecycle: - mainStop.stopButtonPresent && - mainStop.stopButtonVisible && - mainStop.generationStopped && - mainStop.generationIdle, + independentMainTransportStarted: mainSend.reached && mainSend.independentSignal, + independentMainTransportStopped: mainStop.settled && mainStop.mainSignalAborted, noUnexpectedExternalFetch: browserCleanup.unexpectedExternal === 0, - captureUsedOnce: browserCleanup.captureRequests === 1, + // 提示词捕获可在 backend fetch 前停止,以 ready 事件确认恰好捕获一次。 + captureUsedOnce: browserCleanup.captureReadyCount === 1, fetchRestored: browserCleanup.topFetchRestored && browserCleanup.frameFetchRestored, extraAnalysisEnded: browserCleanup.extraAnalysisEnded, artifactLoaded: artifactBundleRequests >= 1, diff --git a/tests/live/run_pi_st_capture_smoke.mjs b/tests/live/run_pi_st_capture_smoke.mjs index ed7a2aa..b292abf 100644 --- a/tests/live/run_pi_st_capture_smoke.mjs +++ b/tests/live/run_pi_st_capture_smoke.mjs @@ -1446,7 +1446,7 @@ async function main() { const listenerMissChecks = { listenerMissCompleted: listenerMissInvocation?.completed === true, - listenerMissHandledByRetryStrategy: listenerMissInvocation?.rejected === false, + listenerMissErrorPropagated: listenerMissInvocation?.rejected === true, listenerMissConvergedWithin10Seconds: Number.isFinite(listenerMissInvocation?.elapsedMs) && listenerMissInvocation.elapsedMs <= LISTENER_MISS_TIMEOUT_MS, diff --git a/tests/live/run_pi_st_openrouter_browser_smoke.mjs b/tests/live/run_pi_st_openrouter_browser_smoke.mjs index 035cef4..e83356d 100644 --- a/tests/live/run_pi_st_openrouter_browser_smoke.mjs +++ b/tests/live/run_pi_st_openrouter_browser_smoke.mjs @@ -32,7 +32,7 @@ const SCRIPT_NAME = 'MVU Pi OpenRouter Browser Smoke'; const SCRIPT_ID = '00000000-0000-4000-8000-000000000044'; const RESULT_MARKER = 'MVU_PI_BROWSER_OK'; const INJECTION_ID = 'MVU_PI_BROWSER_LIVE_RESULT'; -const LIVE_MODEL = 'inclusionai/ling-3.0-flash-fin:free'; +const LIVE_MODEL = process.env.MVU_PI_ST_LIVE_MODEL?.trim() || 'openai/gpt-4.1-mini'; const BROWSER_CREDENTIAL_PLACEHOLDER = 'mvu-browser-credential-placeholder'; const WIRE_PREFLIGHT_CREDENTIAL = 'mvu-browser-preflight-invalid'; const ANTHROPIC_OPENROUTER_HEADER_OVERRIDES = @@ -1044,22 +1044,12 @@ async function configurePiCase(configuration) { if (!iframe?.contentWindow) throw new Error('artifact-iframe-missing'); const panelDocument = document; - const selects = [...panelDocument.querySelectorAll('select')]; - const providerSelect = selects.find(select => { - const values = [...select.options].map(option => option.value); - return ['openai', 'openai-codex', 'anthropic', 'google'].every(value => - values.includes(value) - ); - }); - if (!providerSelect) throw new Error('provider-select-missing'); - setInputValue(providerSelect, configuration.provider, 'change'); - await new Promise(resolve => setTimeout(resolve, 100)); - - const apiSelect = [...panelDocument.querySelectorAll('select')].find(select => - [...select.options].some(option => option.value === configuration.api) + const choice = JSON.stringify([configuration.provider, configuration.api, 'api_key']); + const providerSelect = [...panelDocument.querySelectorAll('select')].find(select => + [...select.options].some(option => option.value === choice) ); - if (!apiSelect) throw new Error('api-select-missing'); - setInputValue(apiSelect, configuration.api, 'change'); + if (!providerSelect) throw new Error('provider-select-missing'); + setInputValue(providerSelect, choice, 'change'); await new Promise(resolve => setTimeout(resolve, 100)); const modelInput = panelDocument.querySelector('.mvu-pi-model-controls input[type="text"]'); @@ -1086,8 +1076,8 @@ async function configurePiCase(configuration) { element => element.getClientRects().length > 0 && element.textContent?.trim() ); return { - providerSelected: providerSelect.value === configuration.provider, - apiSelected: apiSelect.value === configuration.api, + providerSelected: JSON.parse(providerSelect.value)[0] === configuration.provider, + apiSelected: JSON.parse(providerSelect.value)[1] === configuration.api, endpointSelected: endpointInput.value === configuration.endpoint, modelSelected: modelInput.value === configuration.model, credentialLengthMatched: keyInput.value.length === configuration.apiKey.length, @@ -1786,14 +1776,12 @@ async function startPiAbortCase(configuration) { if (window.__mvuPiLiveAbortState) throw new Error('abort-state-already-active'); const retryEventId = iframe.contentWindow.getButtonEvent('重试额外模型解析'); - const stopEventId = iframe.contentWindow.getButtonEvent('停止“更多”额外模型解析'); const retryHandlers = context.eventSource?.events?.[retryEventId]; - const stopHandlers = context.eventSource?.events?.[stopEventId]; if (!Array.isArray(retryHandlers) || retryHandlers.length === 0) { throw new Error('retry-handler-missing'); } - if (!Array.isArray(stopHandlers) || stopHandlers.length === 0) { - throw new Error('stop-handler-missing'); + if (!document.getElementById('mes_stop')) { + throw new Error('stop-button-missing'); } await context.setExtensionPrompt( @@ -1832,7 +1820,7 @@ async function startPiAbortCase(configuration) { iframeOriginalFetch, topWrappedFetch: null, iframeWrappedFetch: null, - stopHandler: stopHandlers.at(-1), + stopHandler: () => document.getElementById('mes_stop').click(), preInvocation, requestCount: 0, paths: [], @@ -1996,7 +1984,7 @@ async function startPiAbortCase(configuration) { started: true, preInvocation, retryHandlerCount: retryHandlers.length, - stopHandlerCount: stopHandlers.length, + stopButtonPresent: Boolean(document.getElementById('mes_stop')), }; } diff --git a/tests/model_list.test.ts b/tests/model_list.test.ts index 3869fd3..fbe0ef2 100644 --- a/tests/model_list.test.ts +++ b/tests/model_list.test.ts @@ -50,13 +50,13 @@ jest.mock('@/function/update/pi/pi_gateway', () => { 'opencode', 'anthropic-messages', 'opencode-anthropic', - 'https://opencode.ai/zen' + 'https://opencode.ai/inference' ), 'opencode-google': catalogModel( 'opencode', 'google-generative-ai', 'opencode-google', - 'https://opencode.ai/zen/v1' + 'https://opencode.ai/inference/v1' ), }, OPENCODE_GO_MODELS: {}, @@ -376,7 +376,7 @@ describe('extra-model model-list discovery', () => { expect(fetchMock.mock.calls[1][0]).toBe('/api/backends/chat-completions/status'); expect(JSON.parse(requestInit(fetchMock.mock.calls[1]).body as string)).toEqual({ - reverse_proxy: 'https://opencode.ai/zen/v1', + reverse_proxy: 'https://opencode.ai/inference/v1', proxy_password: 'opencode-secret', chat_completion_source: 'openai', }); diff --git a/tests/pi_dependency_boundary.test.ts b/tests/pi_dependency_boundary.test.ts index 10ab5a5..9b342c1 100644 --- a/tests/pi_dependency_boundary.test.ts +++ b/tests/pi_dependency_boundary.test.ts @@ -1,9 +1,14 @@ /** - * 测试场景:通过静态源码与产物检查,验证 Pi 只从统一网关导入,并经固定版本 ESM 加载而不嵌入主包。 + * 测试场景:通过静态源码与临时构建产物检查,验证 Pi 只从统一网关导入,并经固定版本 ESM 加载而不嵌入主包。 */ -const { readFileSync } = jest.requireActual('node:fs') as { - readFileSync(path: string, encoding: 'utf8'): string; -}; +const { mkdtempSync, readFileSync, rmSync } = jest.requireActual( + 'node:fs' +) as typeof import('node:fs'); +const { execFileSync } = jest.requireActual( + 'node:child_process' +) as typeof import('node:child_process'); +const { tmpdir } = jest.requireActual('node:os') as typeof import('node:os'); +const { join } = jest.requireActual('node:path') as typeof import('node:path'); const PI_ROOT = '@earendil-works/pi-ai'; const ALLOWED_PI_IMPORTS = [ @@ -78,6 +83,30 @@ function hasLocalModuleSource(source_map: WebpackSourceMap, specifier: string): // 依赖边界:允许的运行时入口、ESM 地址版本与生产产物中的第三方代码覆盖一致。 describe('pi dependency boundary', () => { + let artifact_directory: string; + + beforeAll(() => { + // PR 可以只提交源码;检查本次源码生成的产物,不能依赖仓库里上次发布的 bundle。 + artifact_directory = mkdtempSync(join(tmpdir(), 'mvu-pi-dependency-boundary-')); + execFileSync( + process.execPath, + [ + require.resolve('webpack-cli/bin/cli.js'), + '--mode', + 'production', + '--output-path', + artifact_directory, + ], + { cwd: process.cwd(), encoding: 'utf8', stdio: 'pipe', timeout: 60_000 } + ); + }, 65_000); + + afterAll(() => { + if (artifact_directory) { + rmSync(artifact_directory, { recursive: true, force: true }); + } + }); + test('gateway imports only the audited pi runtime entry points', () => { const source = readWorkspaceFile('src/function/update/pi/pi_gateway.ts'); const imports = new Set( @@ -96,7 +125,7 @@ describe('pi dependency boundary', () => { const manifest = JSON.parse(readWorkspaceFile('package.json')) as { dependencies: Record; }; - const bundle = readWorkspaceFile('artifact/bundle.js'); + const bundle = readFileSync(join(artifact_directory, 'bundle.js'), 'utf8'); const cdn = 'https://testingcf.jsdelivr.net/npm/'; for (const specifier of ALLOWED_PI_IMPORTS) { expect(bundle).toContain( @@ -116,7 +145,7 @@ describe('pi dependency boundary', () => { test('does not embed third-party Pi or provider SDK code in the MVU artifact', () => { const source_map = JSON.parse( - readWorkspaceFile('artifact/bundle.js.map') + readFileSync(join(artifact_directory, 'bundle.js.map'), 'utf8') ) as WebpackSourceMap; for (const specifier of EXTERNAL_PI_SDK_DEPENDENCIES) { expect(hasLocalModuleSource(source_map, specifier)).toBe(false); diff --git a/tests/pi_google_proxy_adapter.test.ts b/tests/pi_google_proxy_adapter.test.ts index 9e777f1..869ec25 100644 --- a/tests/pi_google_proxy_adapter.test.ts +++ b/tests/pi_google_proxy_adapter.test.ts @@ -162,6 +162,9 @@ jest.mock('@/function/update/pi/pi_gateway', () => { choice === 'any' ? 'ANY' : choice === 'none' ? 'NONE' : 'AUTO' ), resolveGoogleThinkingLevel: jest.fn((_model: unknown, level: string) => level), + getDisabledGoogleThinkingConfig: jest.fn(() => ({ thinkingLevel: 'MINIMAL' })), + toGoogleThinkingLevel: jest.fn((level: string) => level.toUpperCase()), + usesGoogleThinkingLevel: jest.fn(() => true), retainGoogleThoughtSignature: jest.fn( (existing: string | undefined, incoming: string | undefined) => incoming && incoming.length > 0 ? incoming : existing @@ -275,7 +278,7 @@ const MODEL: Model<'google-generative-ai'> = { name: 'Gemini 3 Flash', api: 'google-generative-ai', provider: 'opencode', - baseUrl: 'https://opencode.ai/zen/v1', + baseUrl: 'https://opencode.ai/inference/v1', reasoning: true, input: ['text', 'image'], cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }, @@ -550,7 +553,7 @@ describe('Google proxy-aware adapter', () => { const [url, init] = fetch_impl.mock.calls[0]!; const request_init = init!; expect(url).toBe( - 'https://opencode.ai/zen/v1/models/gemini-3-flash:streamGenerateContent?alt=sse' + 'https://opencode.ai/inference/v1/models/gemini-3-flash:streamGenerateContent?alt=sse' ); expect(request_init.method).toBe('POST'); expect((request_init.headers as Headers).get('x-goog-api-key')).toBe('secret-key'); @@ -671,7 +674,7 @@ describe('Google proxy-aware adapter', () => { }) ); const target = - 'https://opencode.ai/zen/v1/models/gemini-3-flash:streamGenerateContent?alt=sse'; + 'https://opencode.ai/inference/v1/models/gemini-3-flash:streamGenerateContent?alt=sse'; expect(result).toMatchObject({ stopReason: 'stop', content: [{ text: 'proxied' }] }); expect(st_fetch).toHaveBeenCalledTimes(2); diff --git a/tests/pi_provider_catalog_contract.test.ts b/tests/pi_provider_catalog_contract.test.ts index 7e0c01f..aca6505 100644 --- a/tests/pi_provider_catalog_contract.test.ts +++ b/tests/pi_provider_catalog_contract.test.ts @@ -8,6 +8,68 @@ const { join } = jest.requireActual('node:path') as typeof import('node:path'); // 真实目录契约:服务商标识、模型所属协议和注册信息必须匹配。 describe('Pi preset provider catalog contract', () => { + test('Argon catalogs send supported thinking levels through the Google proxy adapter', () => { + const script = String.raw` + import assert from 'node:assert/strict'; + import { getPiCatalogModels } from './src/function/update/pi/provider_registry.ts'; + import { createGoogleProxyAwareApi } from './src/function/update/pi/google_proxy_adapter.ts'; + import { googleGenerativeAIApi, normalizeContext } from './src/function/update/pi/pi_gateway.ts'; + globalThis.fetch = async () => { throw new Error('Unexpected network request'); }; + const api = createGoogleProxyAwareApi(googleGenerativeAIApi()); + const context = normalizeContext({ + messages: [{ role: 'user', content: 'hello', timestamp: 0 }], + }); + for (const [provider, id, baseUrl] of [ + ['google', 'gemini-4-argon', 'https://generativelanguage.googleapis.com/v1beta'], + ['opencode', 'gemini-4-argon', 'https://opencode.ai/inference/v1'], + ['opencode-go', 'gemini-4-argon-contributor', 'https://opencode.ai/inference/go/v1'], + ]) { + const model = getPiCatalogModels(provider).find(model => model.id === id); + assert.ok(model, provider + ': missing Argon catalog entry'); + assert.equal(model.api, 'google-generative-ai'); + assert.equal(model.baseUrl, baseUrl); + for (const [reasoning, expected] of [ + [undefined, 'LOW'], ['off', 'LOW'], ['minimal', 'LOW'], + ['low', 'LOW'], ['medium', 'MEDIUM'], ['high', 'HIGH'], + ['xhigh', 'HIGH'], ['max', 'HIGH'], + ]) { + const requests = []; + const result = await api.streamSimple(model, context, { + apiKey: 'test-key', reasoning, maxRetries: 0, + fetch: async (url, init) => { + requests.push({ url: String(url), body: JSON.parse(init.body) }); + const chunk = { candidates: [{ + content: { role: 'model', parts: [{ text: 'ok' }] }, + finishReason: 'STOP', + }] }; + return new Response('data: ' + JSON.stringify(chunk) + '\n\n', { + headers: { 'content-type': 'text/event-stream' }, + }); + }, + }).result(); + assert.equal(result.stopReason, 'stop', result.errorMessage); + assert.equal(requests.length, 1); + assert.equal(requests[0].url, baseUrl + '/models/' + id + ':streamGenerateContent?alt=sse'); + const thinking = requests[0].body.generationConfig.thinkingConfig; + assert.equal(thinking.thinkingLevel, expected, provider + '/' + reasoning); + assert.equal('thinkingBudget' in thinking, false); + } + } + `; + execFileSync( + process.execPath, + [ + '--experimental-strip-types', + '--loader', + join(process.cwd(), 'tests/helpers/typescript-extension-loader.mjs'), + '--input-type=module', + '--eval', + script, + ], + { cwd: process.cwd(), encoding: 'utf8', stdio: 'pipe', timeout: 30_000 } + ); + }); + test('all 34 real catalogs match the real provider registry', () => { const script = String.raw` const { diff --git a/tests/pi_provider_registry.test.ts b/tests/pi_provider_registry.test.ts index f464a23..19d0892 100644 --- a/tests/pi_provider_registry.test.ts +++ b/tests/pi_provider_registry.test.ts @@ -349,9 +349,9 @@ const EXPECTED_PI_PROVIDER_DEFINITIONS = [ }, { key: 'kimi-coding', - defaultApi: 'anthropic-messages', - allowedApis: ['anthropic-messages'], - defaultBaseUrl: 'https://api.kimi.com/coding', + defaultApi: 'openai-completions', + allowedApis: ['openai-completions'], + defaultBaseUrl: 'https://api.kimi.com/coding/v1', fields: API_KEY_FIELDS, }, { @@ -422,24 +422,30 @@ const EXPECTED_PI_PROVIDER_DEFINITIONS = [ 'openai-completions', 'openai-responses', ], - defaultBaseUrl: 'https://opencode.ai/zen', + defaultBaseUrl: 'https://opencode.ai/inference', apiBaseUrls: { - 'anthropic-messages': 'https://opencode.ai/zen', - 'google-generative-ai': 'https://opencode.ai/zen/v1', - 'openai-completions': 'https://opencode.ai/zen/v1', - 'openai-responses': 'https://opencode.ai/zen/v1', + 'anthropic-messages': 'https://opencode.ai/inference', + 'google-generative-ai': 'https://opencode.ai/inference/v1', + 'openai-completions': 'https://opencode.ai/inference/v1', + 'openai-responses': 'https://opencode.ai/inference/v1', }, fields: MULTI_API_KEY_FIELDS, }, { key: 'opencode-go', defaultApi: 'anthropic-messages', - allowedApis: ['anthropic-messages', 'openai-completions', 'openai-responses'], - defaultBaseUrl: 'https://opencode.ai/zen/go', + allowedApis: [ + 'anthropic-messages', + 'google-generative-ai', + 'openai-completions', + 'openai-responses', + ], + defaultBaseUrl: 'https://opencode.ai/inference/go', apiBaseUrls: { - 'anthropic-messages': 'https://opencode.ai/zen/go', - 'openai-completions': 'https://opencode.ai/zen/go/v1', - 'openai-responses': 'https://opencode.ai/zen/go/v1', + 'anthropic-messages': 'https://opencode.ai/inference/go', + 'google-generative-ai': 'https://opencode.ai/inference/go/v1', + 'openai-completions': 'https://opencode.ai/inference/go/v1', + 'openai-responses': 'https://opencode.ai/inference/go/v1', }, fields: MULTI_API_KEY_FIELDS, }, @@ -630,7 +636,7 @@ describe('pi provider registry', () => { 'fireworks/anthropic-messages', 'github-copilot/anthropic-messages', 'github-copilot/openai-responses', - 'kimi-coding/anthropic-messages', + 'kimi-coding/openai-completions', 'minimax-cn/anthropic-messages', 'nvidia/openai-completions', 'openai-codex/openai-codex-responses', @@ -639,6 +645,7 @@ describe('pi provider registry', () => { 'opencode/openai-completions', 'opencode/openai-responses', 'opencode-go/anthropic-messages', + 'opencode-go/google-generative-ai', 'opencode-go/openai-completions', 'opencode-go/openai-responses', ]); diff --git a/tests/pi_runtime.test.ts b/tests/pi_runtime.test.ts index cb13e1b..c57f22c 100644 --- a/tests/pi_runtime.test.ts +++ b/tests/pi_runtime.test.ts @@ -31,7 +31,7 @@ jest.mock('@/function/update/pi/pi_gateway', () => { : provider === 'google' ? 'https://generativelanguage.googleapis.com/v1beta' : provider === 'opencode' - ? 'https://opencode.ai/zen/v1' + ? 'https://opencode.ai/inference/v1' : 'https://chatgpt.com/backend-api', reasoning: id === 'claude-legacy', input, @@ -983,7 +983,7 @@ describe('pi runtime execution', () => { stream.mockImplementation((_model, _context, options) => ({ async *[Symbol.asyncIterator]() { try { - await options.fetch('https://opencode.ai/zen/v1/responses', { + await options.fetch('https://opencode.ai/inference/v1/responses', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"model":"gpt-custom"}', diff --git a/yarn.lock b/yarn.lock index d9f80ef..f4b9847 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22,9 +22,9 @@ __metadata: languageName: node linkType: hard -"@anthropic-ai/sdk@npm:0.124.0": - version: 0.124.0 - resolution: "@anthropic-ai/sdk@npm:0.124.0" +"@anthropic-ai/sdk@npm:0.129.0": + version: 0.129.0 + resolution: "@anthropic-ai/sdk@npm:0.129.0" dependencies: json-schema-to-ts: ^3.1.1 standardwebhooks: ^1.0.0 @@ -35,7 +35,7 @@ __metadata: optional: true bin: anthropic-ai-sdk: bin/cli - checksum: 62176555848662ca94520e673578f518f6acf7e9e9e2d179e28852f03dc6ba0df69700d0ee84a59008788d934fd2d0e2189f2e70b25e03e80ba9b17b40eb885b + checksum: adcf2769cb51facad68b5182d4879de2de9778ee4d5316eb1a6f2ba2ee00956fda26b24dea08ad439c4b774cf37a9c8708a48bc99fcf82987f85dd2ba3d652b2 languageName: node linkType: hard @@ -871,13 +871,13 @@ __metadata: languageName: node linkType: hard -"@earendil-works/pi-ai@npm:1.0.0": - version: 1.0.0 - resolution: "@earendil-works/pi-ai@npm:1.0.0" +"@earendil-works/pi-ai@npm:1.3.0": + version: 1.3.0 + resolution: "@earendil-works/pi-ai@npm:1.3.0" dependencies: - "@anthropic-ai/sdk": 0.124.0 + "@anthropic-ai/sdk": 0.129.0 "@aws-sdk/client-bedrock-runtime": 3.1127.0 - "@earendil-works/pi-telemetry": ^1.0.0 + "@earendil-works/pi-telemetry": 1.3.0 "@google/genai": 2.21.0 "@smithy/node-http-handler": 4.12.1 http-proxy-agent: 9.1.0 @@ -887,14 +887,14 @@ __metadata: typebox: 1.3.27 bin: pi-ai: dist/cli.js - checksum: cd6099a1ba0020357bf3a2b8524a767199a0d3d3d9f081c6874d5beeeb81d452f8dcdd984ad8f50bf4a6b53d9868263265902d9f4927741c41b02c9d5e8a0616 + checksum: 83abfc8f5dd87c5011fa7a16905664fa4da6d4365ec6345063b22c16c6aaf28c012ffd5f5979029a840d44b0502a10041d21589ff3c2bace2be0dd4614fe95d0 languageName: node linkType: hard -"@earendil-works/pi-telemetry@npm:^1.0.0": - version: 1.0.4 - resolution: "@earendil-works/pi-telemetry@npm:1.0.4" - checksum: 8acf25c745044c89cbab9535c6495da13467705e4ac1dea54ab8a216a346538d15eca2abdddac131aaf867e44bc7431792103ba6f54048f2ce63d8a801109d94 +"@earendil-works/pi-telemetry@npm:1.3.0": + version: 1.3.0 + resolution: "@earendil-works/pi-telemetry@npm:1.3.0" + checksum: 8087083d4b07917cdc5d03f74f76a010b430f3bd0af382113a5d9a98939da195094312c08450a2a75c6cacc9c03b450fdcc6007d3508d5580e66a83a125c5213 languageName: node linkType: hard @@ -6977,7 +6977,7 @@ __metadata: version: 0.0.0-use.local resolution: "mag-variable-update@workspace:." dependencies: - "@earendil-works/pi-ai": 1.0.0 + "@earendil-works/pi-ai": 1.3.0 "@eslint/js": ^9.29.0 "@google/genai": 2.21.0 "@intlify/devtools-types": 11.1.12