From 568fe4523ab072c250b0acf62c56672e3d9da7f9 Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 1 Oct 2026 18:04:46 -0400 Subject: [PATCH 1/5] fix(agent-core-v2): send x-opencode-session on the llm-adapter request path OpenCode Go rejects requests without a stable per-conversation session id. The header was only built in the legacy kosong requester, which the engine no longer uses, and even there it read a conversationId that turns never set. Derive the header from the session cache key in the live llm-adapter requester and give the connectivity probe a random session id. --- .changeset/opencode-go-session-header.md | 6 +++++ .../src/kosong/model/modelRequesterImpl.ts | 7 ++++-- .../src/llm-adapter/model/catalog-service.ts | 4 +++- .../llm-adapter/model/model-requester-impl.ts | 15 +++++++++++- .../model/opencode-session.ts} | 0 .../llm-adapter/model/modelRequester.test.ts | 23 +++++++++++++++++++ 6 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 .changeset/opencode-go-session-header.md rename packages/agent-core-v2/src/{kosong/model/opencodeSession.ts => llm-adapter/model/opencode-session.ts} (100%) diff --git a/.changeset/opencode-go-session-header.md b/.changeset/opencode-go-session-header.md new file mode 100644 index 000000000..587f63e42 --- /dev/null +++ b/.changeset/opencode-go-session-header.md @@ -0,0 +1,6 @@ +--- +"@pymodel/pythinker-code": patch +"@pymodel/pythinker-desktop": patch +--- + +Fix OpenCode Go requests failing with "Request is missing x-opencode-session". diff --git a/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts b/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts index 26bfcc147..330670ce9 100644 --- a/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts +++ b/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts @@ -12,7 +12,7 @@ import type { import { translateProviderError } from '#/kosong/protocol/errors'; import type { IProtocolAdapterRegistry } from '#/kosong/protocol/protocol'; -import { opencodeSessionHeaders } from './opencodeSession'; +import { opencodeSessionHeaders } from '#/llm-adapter/model/opencode-session'; import type { AuthProvider, Model } from './catalog'; import type { @@ -92,7 +92,10 @@ export class ModelRequesterImpl implements ModelRequester { const options: GenerateOptions = { signal, cacheKey: params?.cacheKey, - extraHeaders: opencodeSessionHeaders(this.model.baseUrl, params?.conversationId), + extraHeaders: opencodeSessionHeaders( + this.model.baseUrl, + params?.conversationId ?? params?.cacheKey, + ), sampling: params?.sampling, thinking: params?.thinkingEffort === undefined diff --git a/packages/agent-core-v2/src/llm-adapter/model/catalog-service.ts b/packages/agent-core-v2/src/llm-adapter/model/catalog-service.ts index f071aebdb..362833faf 100644 --- a/packages/agent-core-v2/src/llm-adapter/model/catalog-service.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/catalog-service.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto'; + import { parsePythinkerCodeCustomHeaders } from '@pymodel/pythinker-code-oauth'; import { Disposable } from '#/_base/di/lifecycle'; @@ -171,7 +173,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }], toolCalls: [] }], }, undefined, - { maxCompletionTokens: 512 }, + { cacheKey: randomUUID(), maxCompletionTokens: 512 }, )) { if (event.type === 'part' && event.part.type === 'text') { text += event.part.text; diff --git a/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts b/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts index 19c1110b0..fc73dd71b 100644 --- a/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts @@ -17,6 +17,7 @@ import { type LlmRequestContent, type LlmRequestEvent, type LlmRequester, + mergeRequestHeaders, } from '#human/llm/requester/requester'; import type { TokenUsage } from '#human/llm/usage'; @@ -32,6 +33,7 @@ import { fromLlmAssistantMessage, toLlmMessage, type Tool } from '../contract/me import { mergeUsagePatch } from '#human/llm/usage'; import type { Model } from './catalog'; +import { opencodeSessionHeaders } from './opencode-session'; import type { ModelRequestEvent, ModelRequestInput, @@ -159,8 +161,19 @@ export class ModelRequesterImpl implements ModelRequester { }; const credential = await this.model.credentialProvider?.resolve(); + const credentialed = applyCredential(resolved.model, credential); + const sessionHeaders = opencodeSessionHeaders(credentialed.baseUrl, params?.cacheKey); await requester.generate( - { ...config, model: applyCredential(resolved.model, credential) }, + { + ...config, + model: + sessionHeaders === undefined + ? credentialed + : { + ...credentialed, + defaultHeaders: mergeRequestHeaders(credentialed.defaultHeaders, sessionHeaders), + }, + }, content, { signal: signal ?? new AbortController().signal, diff --git a/packages/agent-core-v2/src/kosong/model/opencodeSession.ts b/packages/agent-core-v2/src/llm-adapter/model/opencode-session.ts similarity index 100% rename from packages/agent-core-v2/src/kosong/model/opencodeSession.ts rename to packages/agent-core-v2/src/llm-adapter/model/opencode-session.ts diff --git a/packages/agent-core-v2/test/llm-adapter/model/modelRequester.test.ts b/packages/agent-core-v2/test/llm-adapter/model/modelRequester.test.ts index c6af97c74..61f3f556c 100644 --- a/packages/agent-core-v2/test/llm-adapter/model/modelRequester.test.ts +++ b/packages/agent-core-v2/test/llm-adapter/model/modelRequester.test.ts @@ -467,3 +467,26 @@ describe('buildStreamTiming', () => { }); }); }); + +describe('ModelRequesterImpl OpenCode session header', () => { + async function headersFor(baseUrl: string, cacheKey: string | undefined) { + const requester = new FakeLlmRequester(); + requester.handler = (_i, emit) => textStream(emit); + const impl = new ModelRequesterImpl(modelWith(undefined), { + resolve: () => ({ requester, protocol: 'openai', model: { ...BASE_LLM_MODEL, baseUrl } }), + }); + await collect(impl.request(INPUT, undefined, { cacheKey })); + return requester.calls[0]!.config.model.defaultHeaders; + } + + it('sends the session id as x-opencode-session to the OpenCode gateway', async () => { + expect(await headersFor('https://opencode.ai/zen/go/v1', 'session-1')).toEqual({ + 'x-opencode-session': 'session-1', + }); + }); + + it('does not send the header to other providers or without a session id', async () => { + expect(await headersFor('https://api.example.test/v1', 'session-1')).toBeUndefined(); + expect(await headersFor('https://opencode.ai/zen/go/v1', undefined)).toBeUndefined(); + }); +}); From 046cc50085eed37d0698a0698a772bc5dd003c14 Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 1 Oct 2026 18:37:50 -0400 Subject: [PATCH 2/5] fix(agent-core-v2): keep a user-configured x-opencode-session header --- .../llm-adapter/model/model-requester-impl.ts | 6 +++++- .../src/llm-adapter/model/opencode-session.ts | 4 ++++ .../llm-adapter/model/modelRequester.test.ts | 20 +++++++++++++++++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts b/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts index fc73dd71b..0398bdfd4 100644 --- a/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts @@ -162,7 +162,11 @@ export class ModelRequesterImpl implements ModelRequester { const credential = await this.model.credentialProvider?.resolve(); const credentialed = applyCredential(resolved.model, credential); - const sessionHeaders = opencodeSessionHeaders(credentialed.baseUrl, params?.cacheKey); + const sessionHeaders = opencodeSessionHeaders( + credentialed.baseUrl, + params?.cacheKey, + credentialed.defaultHeaders, + ); await requester.generate( { ...config, diff --git a/packages/agent-core-v2/src/llm-adapter/model/opencode-session.ts b/packages/agent-core-v2/src/llm-adapter/model/opencode-session.ts index 1cd2d7bad..a04a5127a 100644 --- a/packages/agent-core-v2/src/llm-adapter/model/opencode-session.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/opencode-session.ts @@ -18,8 +18,12 @@ export function isOpencodeGatewayBaseUrl(baseUrl: string | undefined): boolean { export function opencodeSessionHeaders( baseUrl: string | undefined, conversationId: string | undefined, + existing?: Readonly>, ): Record | undefined { const id = conversationId?.trim(); if (!isOpencodeGatewayBaseUrl(baseUrl) || id === undefined || id.length === 0) return undefined; + if (Object.keys(existing ?? {}).some((key) => key.toLowerCase() === OPENCODE_SESSION_HEADER)) { + return undefined; + } return { [OPENCODE_SESSION_HEADER]: id }; } diff --git a/packages/agent-core-v2/test/llm-adapter/model/modelRequester.test.ts b/packages/agent-core-v2/test/llm-adapter/model/modelRequester.test.ts index 61f3f556c..98136c7b0 100644 --- a/packages/agent-core-v2/test/llm-adapter/model/modelRequester.test.ts +++ b/packages/agent-core-v2/test/llm-adapter/model/modelRequester.test.ts @@ -469,11 +469,19 @@ describe('buildStreamTiming', () => { }); describe('ModelRequesterImpl OpenCode session header', () => { - async function headersFor(baseUrl: string, cacheKey: string | undefined) { + async function headersFor( + baseUrl: string, + cacheKey: string | undefined, + defaultHeaders?: Record, + ) { const requester = new FakeLlmRequester(); requester.handler = (_i, emit) => textStream(emit); const impl = new ModelRequesterImpl(modelWith(undefined), { - resolve: () => ({ requester, protocol: 'openai', model: { ...BASE_LLM_MODEL, baseUrl } }), + resolve: () => ({ + requester, + protocol: 'openai', + model: { ...BASE_LLM_MODEL, baseUrl, defaultHeaders }, + }), }); await collect(impl.request(INPUT, undefined, { cacheKey })); return requester.calls[0]!.config.model.defaultHeaders; @@ -489,4 +497,12 @@ describe('ModelRequesterImpl OpenCode session header', () => { expect(await headersFor('https://api.example.test/v1', 'session-1')).toBeUndefined(); expect(await headersFor('https://opencode.ai/zen/go/v1', undefined)).toBeUndefined(); }); + + it('keeps a session header the user configured', async () => { + expect( + await headersFor('https://opencode.ai/zen/go/v1', 'session-1', { + 'X-OpenCode-Session': 'custom', + }), + ).toEqual({ 'X-OpenCode-Session': 'custom' }); + }); }); From 222a09b34f4af833af272b7083677a2ac97d58d6 Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 1 Oct 2026 18:59:26 -0400 Subject: [PATCH 3/5] refactor(agent-core-v2): remove the legacy kosong layer Route every importer to llm-adapter and #human, then delete src/kosong. Port the features that only lived in the legacy copy: OpenCode billing rejections are provider errors, DSML/Hermes text tool calls are recovered on the OpenAI stream, and modelRecordProviderId. The auth and env-overlay checks now read the full provider-definition registry, so vendor API keys and base URLs in a provider env table are honored. --- .changeset/dsml-tool-call-recovery.md | 6 + .changeset/opencode-billing-error.md | 6 + .changeset/provider-env-api-key-auth.md | 6 + apps/pythinker-code/src/cli/sub/provider.ts | 2 +- apps/vis/server/src/lib/context-projector.ts | 4 +- .../server/test/lib/context-projector.test.ts | 2 +- packages/agent-core-v2/src/app/auth/auth.ts | 2 +- .../agent-core-v2/src/app/auth/authService.ts | 6 +- .../src/app/auth/authStatusService.ts | 6 +- .../src/app/auth/configSection.ts | 2 +- .../agent-core-v2/src/app/config/errors.ts | 2 +- .../src/app/kosongConfig/configSection.ts | 8 +- .../src/app/kosongConfig/discoveryService.ts | 8 +- .../src/app/kosongConfig/envOverlay.ts | 6 +- .../kosongConfig/hostRequestHeadersAdapter.ts | 2 +- .../app/kosongConfig/kosongConfigService.ts | 4 +- .../src/app/kosongConfig/modelsDev.ts | 6 +- .../src/app/kosongConfig/modelsDevImport.ts | 2 +- .../kosongConfig/modelsDevImportService.ts | 8 +- .../src/app/kosongConfig/modelsDevUpstream.ts | 4 +- .../src/app/kosongConfig/oauthTokenAdapter.ts | 6 +- .../src/app/plugin/pluginService.ts | 2 +- .../src/features/cron/cronAgentRuntime.ts | 2 +- .../bases/openai/dsml-tool-parser.ts | 2 +- .../llm/requester/bases/openai/format.ts | 16 +- .../llm/requester/bases/openai/requester.ts | 43 +- .../llm/dsml-tool-parser-conformance.test.ts | 333 +++++ .../human/test/llm/dsml-tool-parser.test.ts | 265 ++++ .../src/kosong/contract/capability.ts | 44 - .../src/kosong/contract/errors.ts | 511 ------- .../src/kosong/contract/generate.ts | 276 ---- .../src/kosong/contract/inspection.ts | 17 - .../src/kosong/contract/message.ts | 150 -- .../src/kosong/contract/messageHelpers.ts | 11 - .../src/kosong/contract/provider.ts | 103 -- .../src/kosong/contract/requestTrace.ts | 3 - .../src/kosong/contract/tokens.ts | 81 -- .../agent-core-v2/src/kosong/contract/tool.ts | 6 - .../src/kosong/contract/usage.ts | 48 - .../agent-core-v2/src/kosong/model/catalog.ts | 194 --- .../src/kosong/model/catalogService.ts | 644 --------- .../src/kosong/model/completionBudget.ts | 51 - .../src/kosong/model/defaultModelPolicy.ts | 67 - .../agent-core-v2/src/kosong/model/errors.ts | 1 - .../src/kosong/model/hostRequestHeaders.ts | 9 - .../src/kosong/model/inspection.ts | 497 ------- .../agent-core-v2/src/kosong/model/model.ts | 88 -- .../src/kosong/model/model.types.ts | 40 - .../src/kosong/model/modelAuth.ts | 236 ---- .../src/kosong/model/modelOAuth.ts | 17 - .../src/kosong/model/modelRequester.ts | 73 - .../src/kosong/model/modelRequesterImpl.ts | 228 --- .../src/kosong/model/modelService.ts | 178 --- .../src/kosong/model/thinking.ts | 218 --- .../src/kosong/protocol/errors.ts | 5 - .../src/kosong/protocol/protocol.ts | 72 - .../src/kosong/protocol/protocolBase.ts | 41 - .../src/kosong/protocol/protocolTrait.ts | 119 -- .../bases/anthropic/anthropic-profile.ts | 148 -- .../bases/anthropic/anthropic.contrib.ts | 33 - .../provider/bases/anthropic/anthropic.ts | 1188 ---------------- .../bases/anthropic/anthropicHooks.ts | 23 - .../kosong/provider/bases/anthropic/index.ts | 1 - .../google-genai/google-genai.contrib.ts | 31 - .../bases/google-genai/google-genai.ts | 932 ------------- .../provider/bases/google-genai/index.ts | 1 - .../provider/bases/merge-user-messages.ts | 25 - .../bases/openai/chat-completions-stream.ts | 97 -- .../src/kosong/provider/bases/openai/index.ts | 2 - .../provider/bases/openai/openai-common.ts | 282 ---- .../bases/openai/openai-legacy.contrib.ts | 38 - .../provider/bases/openai/openai-legacy.ts | 819 ----------- .../bases/openai/openai-responses.contrib.ts | 31 - .../provider/bases/openai/openai-responses.ts | 1239 ----------------- .../provider/bases/openai/openaiHooks.ts | 141 -- .../provider/bases/openai/reasoning-key.ts | 42 - .../src/kosong/provider/bases/request-auth.ts | 47 - .../src/kosong/provider/bases/tool-call-id.ts | 124 -- .../provider/protocolAdapterRegistry.ts | 123 -- .../src/kosong/provider/provider.ts | 57 - .../src/kosong/provider/providerDefinition.ts | 164 --- .../src/kosong/provider/providerService.ts | 108 -- .../providers/pythinker/pythinker-errors.ts | 63 - .../providers/pythinker/pythinker-files.ts | 148 -- .../providers/pythinker/pythinker-schema.ts | 437 ------ .../providers/pythinker/pythinker.contrib.ts | 260 ---- .../provider/providers/standard.contrib.ts | 31 - .../agent-core-v2/src/kosong/recordDiff.ts | 49 - .../src/llm-adapter/contract/errors.ts | 10 + .../src/llm-adapter/model/model.ts | 4 + .../subagent/subagentModelPolicyService.ts | 4 +- .../agent-core-v2/test/.skip/thinking.test.ts | 2 +- .../agent-core-v2/test/app/auth/auth.test.ts | 15 +- .../contract/opencode-billing-error.test.ts | 48 + .../llm-adapter/model/modelService.test.ts | 127 -- scripts/check-identity-freeze.mjs | 1 - 96 files changed, 788 insertions(+), 10896 deletions(-) create mode 100644 .changeset/dsml-tool-call-recovery.md create mode 100644 .changeset/opencode-billing-error.md create mode 100644 .changeset/provider-env-api-key-auth.md rename packages/agent-core-v2/src/{kosong/provider => human/llm/requester}/bases/openai/dsml-tool-parser.ts (99%) create mode 100644 packages/agent-core-v2/src/human/test/llm/dsml-tool-parser-conformance.test.ts create mode 100644 packages/agent-core-v2/src/human/test/llm/dsml-tool-parser.test.ts delete mode 100644 packages/agent-core-v2/src/kosong/contract/capability.ts delete mode 100644 packages/agent-core-v2/src/kosong/contract/errors.ts delete mode 100644 packages/agent-core-v2/src/kosong/contract/generate.ts delete mode 100644 packages/agent-core-v2/src/kosong/contract/inspection.ts delete mode 100644 packages/agent-core-v2/src/kosong/contract/message.ts delete mode 100644 packages/agent-core-v2/src/kosong/contract/messageHelpers.ts delete mode 100644 packages/agent-core-v2/src/kosong/contract/provider.ts delete mode 100644 packages/agent-core-v2/src/kosong/contract/requestTrace.ts delete mode 100644 packages/agent-core-v2/src/kosong/contract/tokens.ts delete mode 100644 packages/agent-core-v2/src/kosong/contract/tool.ts delete mode 100644 packages/agent-core-v2/src/kosong/contract/usage.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/catalog.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/catalogService.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/completionBudget.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/defaultModelPolicy.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/errors.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/inspection.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/model.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/model.types.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/modelAuth.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/modelOAuth.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/modelRequester.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/modelService.ts delete mode 100644 packages/agent-core-v2/src/kosong/model/thinking.ts delete mode 100644 packages/agent-core-v2/src/kosong/protocol/errors.ts delete mode 100644 packages/agent-core-v2/src/kosong/protocol/protocol.ts delete mode 100644 packages/agent-core-v2/src/kosong/protocol/protocolBase.ts delete mode 100644 packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic-profile.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropicHooks.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/anthropic/index.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.contrib.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/google-genai/index.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/merge-user-messages.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/openai/chat-completions-stream.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/openai/index.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.contrib.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.contrib.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/openai/openaiHooks.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/openai/reasoning-key.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/request-auth.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/bases/tool-call-id.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/protocolAdapterRegistry.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/provider.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/providerDefinition.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/providerService.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/providers/pythinker/pythinker-errors.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/providers/pythinker/pythinker-files.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/providers/pythinker/pythinker-schema.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/providers/pythinker/pythinker.contrib.ts delete mode 100644 packages/agent-core-v2/src/kosong/provider/providers/standard.contrib.ts delete mode 100644 packages/agent-core-v2/src/kosong/recordDiff.ts create mode 100644 packages/agent-core-v2/test/llm-adapter/contract/opencode-billing-error.test.ts delete mode 100644 packages/agent-core-v2/test/llm-adapter/model/modelService.test.ts diff --git a/.changeset/dsml-tool-call-recovery.md b/.changeset/dsml-tool-call-recovery.md new file mode 100644 index 000000000..f09ad0447 --- /dev/null +++ b/.changeset/dsml-tool-call-recovery.md @@ -0,0 +1,6 @@ +--- +"@pymodel/pythinker-code": patch +"@pymodel/pythinker-desktop": patch +--- + +Run tool calls that some models (such as DeepSeek) write as DSML or text instead of showing them as plain text. diff --git a/.changeset/opencode-billing-error.md b/.changeset/opencode-billing-error.md new file mode 100644 index 000000000..9f893c07f --- /dev/null +++ b/.changeset/opencode-billing-error.md @@ -0,0 +1,6 @@ +--- +"@pymodel/pythinker-code": patch +"@pymodel/pythinker-desktop": patch +--- + +Report an insufficient-balance response from OpenAI-compatible providers as a billing error instead of an authentication error. diff --git a/.changeset/provider-env-api-key-auth.md b/.changeset/provider-env-api-key-auth.md new file mode 100644 index 000000000..5bcb98694 --- /dev/null +++ b/.changeset/provider-env-api-key-auth.md @@ -0,0 +1,6 @@ +--- +"@pymodel/pythinker-code": patch +"@pymodel/pythinker-desktop": patch +--- + +Accept a vendor API key set in a provider's env table (for example ANTHROPIC_API_KEY) instead of reporting the model as not logged in. diff --git a/apps/pythinker-code/src/cli/sub/provider.ts b/apps/pythinker-code/src/cli/sub/provider.ts index 8522e451a..381ec4a14 100644 --- a/apps/pythinker-code/src/cli/sub/provider.ts +++ b/apps/pythinker-code/src/cli/sub/provider.ts @@ -406,7 +406,7 @@ export async function handleCatalogAdd( // Always restore `[thinking]` from what was there before — including // `undefined`. Persisting `enabled: false` when the user never set it would - // make `resolveThinkingEffort` (agent-core-v2/src/kosong/model/thinking.ts) treat + // make `resolveThinkingEffort` (agent-core-v2/src/llm-adapter/model/thinking.ts) treat // it as an explicit "off" request and silently disable thinking, even for // thinking-capable models. config.thinking = previousThinking; diff --git a/apps/vis/server/src/lib/context-projector.ts b/apps/vis/server/src/lib/context-projector.ts index fc774cf2b..3c6c4d7dc 100644 --- a/apps/vis/server/src/lib/context-projector.ts +++ b/apps/vis/server/src/lib/context-projector.ts @@ -10,7 +10,7 @@ import { selectCompactionUserMessages, selectRecentUserMessages, } from '@pymodel/agent-core-v2/agent/contextMemory/compactionHandoff'; -import { estimateTokensForMessages } from '@pymodel/agent-core-v2/kosong/contract/tokens'; +import { estimateTokensForMessages } from '@pymodel/agent-core-v2/llm-adapter/contract/tokens'; import { renderToolResultForModel } from '@pymodel/agent-core-v2/agent/contextMemory/toolResultRender'; import type { ContentPart, @@ -678,7 +678,7 @@ const MICRO_MIN_CONTENT_TOKENS = 100; /** Replicates the engine's per-char token weighting exactly, over the same * `text` + `think` parts its gate counts. The engine - * (`packages/agent-core-v2/src/kosong/contract/tokens.ts`) sums per-part + * (`packages/agent-core-v2/src/llm-adapter/contract/tokens.ts`) sums per-part * estimates, each * `estimateTokens(s) = Math.ceil(asciiCount / 4) + nonAsciiCount` (ASCII ~4 * chars/token, every non-ASCII/CJK code point a full token); other part types diff --git a/apps/vis/server/test/lib/context-projector.test.ts b/apps/vis/server/test/lib/context-projector.test.ts index 032e1d02f..ed11ae876 100644 --- a/apps/vis/server/test/lib/context-projector.test.ts +++ b/apps/vis/server/test/lib/context-projector.test.ts @@ -1,6 +1,6 @@ // apps/vis/server/test/lib/context-projector.test.ts import { describe, it, expect, afterEach } from 'vitest'; -import { estimateTokensForMessages } from '@pymodel/agent-core-v2/kosong/contract/tokens'; +import { estimateTokensForMessages } from '@pymodel/agent-core-v2/llm-adapter/contract/tokens'; import { buildCompactionContinuationText } from '@pymodel/agent-core-v2/agent/contextMemory/compactionHandoff'; import { buildSessionFixture } from '../fixtures/build'; import { projectContext } from '../../src/lib/context-projector'; diff --git a/packages/agent-core-v2/src/app/auth/auth.ts b/packages/agent-core-v2/src/app/auth/auth.ts index 2f2213ab0..1c182f501 100644 --- a/packages/agent-core-v2/src/app/auth/auth.ts +++ b/packages/agent-core-v2/src/app/auth/auth.ts @@ -1,7 +1,7 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import { Error2 } from '#/_base/errors/errors'; -import type { OAuthRef } from '#/kosong/provider/provider'; +import type { OAuthRef } from '#/llm-adapter/provider/provider'; import { AuthErrors } from './errors'; diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index 89c32daa4..801a76649 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -14,15 +14,15 @@ import { type ILogger, ILogService } from '#/_base/log/log'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { LifecycleScope } from '#/app/scopes'; -import { IModelService, type ModelRecord } from '#/kosong/model/model'; +import { IModelService, type ModelRecord } from '#/llm-adapter/model/model'; import { effectiveModelConfig, nonEmpty, providerNameFromFlatModel, resolveModelAuthMaterial, resolveModelForReady, -} from '#/kosong/model/modelAuth'; -import { IProviderService, type OAuthRef } from '#/kosong/provider/provider'; +} from '#/llm-adapter/model/model-auth'; +import { IProviderService, type OAuthRef } from '#/llm-adapter/provider/provider'; import { AuthModelNotResolvedError, diff --git a/packages/agent-core-v2/src/app/auth/authStatusService.ts b/packages/agent-core-v2/src/app/auth/authStatusService.ts index 0219067c7..58eae1142 100644 --- a/packages/agent-core-v2/src/app/auth/authStatusService.ts +++ b/packages/agent-core-v2/src/app/auth/authStatusService.ts @@ -1,8 +1,8 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { LifecycleScope } from '#/app/scopes'; -import { IModelService } from '#/kosong/model/model'; -import { resolveModelForReady } from '#/kosong/model/modelAuth'; -import { IProviderService } from '#/kosong/provider/provider'; +import { IModelService } from '#/llm-adapter/model/model'; +import { resolveModelForReady } from '#/llm-adapter/model/model-auth'; +import { IProviderService } from '#/llm-adapter/provider/provider'; import type { AuthSummary } from './authStatus'; import { IAuthStatusService } from './authStatus'; diff --git a/packages/agent-core-v2/src/app/auth/configSection.ts b/packages/agent-core-v2/src/app/auth/configSection.ts index 92b411544..024afbb82 100644 --- a/packages/agent-core-v2/src/app/auth/configSection.ts +++ b/packages/agent-core-v2/src/app/auth/configSection.ts @@ -19,7 +19,7 @@ import { transformPlainObject, } from '#/app/config/toml'; import { type AssertExact, type Equal } from '#/_base/utils/typeEquality'; -import type { OAuthRef } from '#/kosong/provider/provider'; +import type { OAuthRef } from '#/llm-adapter/provider/provider'; export const SERVICES_SECTION = 'services'; diff --git a/packages/agent-core-v2/src/app/config/errors.ts b/packages/agent-core-v2/src/app/config/errors.ts index 518fda1c7..33b93a004 100644 --- a/packages/agent-core-v2/src/app/config/errors.ts +++ b/packages/agent-core-v2/src/app/config/errors.ts @@ -1,5 +1,5 @@ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; -import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors'; +import { CONFIG_INVALID_ERROR_CODE } from '#/llm-adapter/contract/errors'; export const ConfigErrors = { codes: { diff --git a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts index ede868e7c..2fd44518d 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts @@ -16,10 +16,10 @@ import { transformPlainObject, } from '#/app/config/toml'; import { type AssertExact, type Equal } from '#/_base/utils/typeEquality'; -import type { ModelOverride, ModelRecord, ModelsSection } from '#/kosong/model/model'; -import type { ThinkingConfig } from '#/kosong/model/thinking'; -import type { OAuthRef, ProviderConfig, ProvidersSection } from '#/kosong/provider/provider'; -import { ProtocolSchema } from '#/kosong/protocol/protocol'; +import type { ModelOverride, ModelRecord, ModelsSection } from '#/llm-adapter/model/model'; +import type { ThinkingConfig } from '#/llm-adapter/model/thinking'; +import type { OAuthRef, ProviderConfig, ProvidersSection } from '#/llm-adapter/provider/provider'; +import { ProtocolSchema } from '#/llm-adapter/protocol/protocol'; export const PROVIDERS_SECTION = 'providers'; diff --git a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts index 699bcfda0..09c60caab 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts @@ -10,14 +10,14 @@ import { Error2 } from '#/_base/errors/errors'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IConfigService } from '#/app/config/config'; import { IEventService } from '#/app/event/event'; -import { ModelCatalogErrors } from '#/kosong/model/errors'; -import { modelRecordProviderId, type ModelRecord } from '#/kosong/model/model'; +import { ModelCatalogErrors } from '#/llm-adapter/model/errors'; +import { modelRecordProviderId, type ModelRecord } from '#/llm-adapter/model/model'; import { IProviderService, type ModelSource, type ProviderConfig, -} from '#/kosong/provider/provider'; -import { getProviderDefinition } from '#/kosong/provider/providerDefinition'; +} from '#/llm-adapter/provider/provider'; +import { getProviderDefinition } from '#/llm-adapter/provider/provider-definition'; import { DEFAULT_MODEL_SECTION, diff --git a/packages/agent-core-v2/src/app/kosongConfig/envOverlay.ts b/packages/agent-core-v2/src/app/kosongConfig/envOverlay.ts index c3a901832..885312f91 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/envOverlay.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/envOverlay.ts @@ -3,12 +3,12 @@ import { Error2 } from '#/_base/errors/errors'; import type { ConfigEffectiveOverlay } from '#/app/config/config'; import { registerConfigOverlay } from '#/app/config/configOverlayContributions'; -import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors'; -import { resolveProviderEndpoint } from '#/kosong/provider/providerDefinition'; +import { CONFIG_INVALID_ERROR_CODE } from '#/llm-adapter/contract/errors'; +import { resolveProviderEndpoint } from '#/llm-adapter/provider/provider-definition'; import { PYTHINKER_BASE_URL_ENV, PYTHINKER_DEFAULT_BASE_URL, -} from '#/kosong/provider/providers/pythinker/pythinker.contrib'; +} from '#human/llm-pythinker/trait'; import { ENV_MODEL_PROVIDER_KEY } from './configSection'; diff --git a/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts b/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts index 52c931256..4525e708f 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts @@ -2,7 +2,7 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; +import { IHostRequestHeaders } from '#/llm-adapter/model/host-request-headers'; export class HostRequestHeadersAdapter implements IHostRequestHeaders { readonly headers: Readonly>; diff --git a/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts b/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts index 60dff6209..1498269be 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts @@ -7,8 +7,8 @@ import { retryBackoffDelays, sleepForRetry } from '#/_base/utils/retry'; import { type ConfigSectionChangedEvent, IConfigService } from '#/app/config/config'; import { describeUnknownError } from '#/app/config/configPure'; import { deepEqual } from '#/app/config/sectionDiff'; -import { IModelService, type ModelsSection } from '#/kosong/model/model'; -import { IProviderService, type ProvidersSection } from '#/kosong/provider/provider'; +import { IModelService, type ModelsSection } from '#/llm-adapter/model/model'; +import { IProviderService, type ProvidersSection } from '#/llm-adapter/provider/provider'; import { IKosongConfigService } from './kosongConfig'; import { diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts index d83eee083..35d915981 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts @@ -1,7 +1,7 @@ -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { ProviderType } from '#/kosong/provider/provider'; +import type { ModelCapability } from '#/llm-adapter/contract/capability'; +import type { ProviderType } from '#/llm-adapter/provider/provider'; -import { wireHasProtocolThinkingDisable } from '#/kosong/model/thinking'; +import { wireHasProtocolThinkingDisable } from '#/llm-adapter/model/thinking'; export interface ModelsDevModelEntry { readonly id?: string; diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImport.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImport.ts index de38b3a13..d9c9f90a8 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImport.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImport.ts @@ -1,5 +1,5 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { ProviderCatalogItem } from '#/kosong/model/catalog'; +import type { ProviderCatalogItem } from '#/llm-adapter/model/catalog'; export interface ModelsDevModelItem { readonly id: string; diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts index 51516200f..0a0a5e380 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts @@ -11,9 +11,9 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Error2 } from '#/_base/errors/errors'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IConfigService } from '#/app/config/config'; -import { IModelCatalog } from '#/kosong/model/catalog'; -import { IModelService, modelRecordProviderId, type ModelsSection } from '#/kosong/model/model'; -import { type ProviderConfig, type ProvidersSection } from '#/kosong/provider/provider'; +import { IModelCatalog } from '#/llm-adapter/model/catalog'; +import { IModelService, modelRecordProviderId, type ModelsSection } from '#/llm-adapter/model/model'; +import { type ProviderConfig, type ProvidersSection } from '#/llm-adapter/provider/provider'; import { modelsDevProviderModels, resolveModelsDevImport } from './modelsDev'; import { DEFAULT_MODEL_SECTION, MODELS_SECTION, PROVIDERS_SECTION } from './configSection'; @@ -176,7 +176,6 @@ export class ModelsDevImportService implements IModelsDevImportService { await this.seedDefaultModelWhenUnset(config, `${targetId}/${firstModel.id}`); } - await this.models.settled; const imported = await this.modelCatalog.getProvider(targetId); return { provider: imported, modelsImported: models.length }; } @@ -258,7 +257,6 @@ export class ModelsDevImportService implements IModelsDevImportService { } } - await this.models.settled; const imported = []; for (const entry of Object.values(entries)) { imported.push(await this.modelCatalog.getProvider(entry.id)); diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts index 2689ebd0a..2a1ced10f 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts @@ -1,7 +1,7 @@ import { CoreErrors } from '#/_base/errors/codes'; import { BugIndicatingError, Error2 } from '#/_base/errors/errors'; -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { ModelRecord } from '#/kosong/model/model'; +import type { ModelCapability } from '#/llm-adapter/contract/capability'; +import type { ModelRecord } from '#/llm-adapter/model/model'; import { BUILT_IN_MODELS_DEV_JSON } from './builtInModelsDev'; import { ModelsDevImportErrors } from './errors'; diff --git a/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts b/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts index d3b648fed..aa08b28e5 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts @@ -5,9 +5,9 @@ import { Error2 } from '#/_base/errors/errors'; import { IOAuthTokenService } from '#/app/auth/auth'; import { AuthErrors } from '#/app/auth/errors'; -import { nonEmpty } from '#/kosong/model/modelAuth'; -import { IModelOAuthTokens } from '#/kosong/model/modelOAuth'; -import type { OAuthRef } from '#/kosong/provider/provider'; +import { nonEmpty } from '#/llm-adapter/model/model-auth'; +import { IModelOAuthTokens } from '#/llm-adapter/model/model-oauth'; +import type { OAuthRef } from '#/llm-adapter/provider/provider'; export class ModelOAuthTokenAdapter implements IModelOAuthTokens { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/app/plugin/pluginService.ts b/packages/agent-core-v2/src/app/plugin/pluginService.ts index e21fe3c80..9cb9d6f08 100644 --- a/packages/agent-core-v2/src/app/plugin/pluginService.ts +++ b/packages/agent-core-v2/src/app/plugin/pluginService.ts @@ -9,7 +9,7 @@ import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ISkillDiscovery } from '#/features/skill/catalog/skillDiscovery'; import type { SkillRoot } from '#/features/skill/catalog/types'; import { BugIndicatingError, Error2, PluginErrors } from '#/errors'; -import { IProviderService } from '#/kosong/provider/provider'; +import { IProviderService } from '#/llm-adapter/provider/provider'; import type { HookDef } from '#/features/externalHooks/internal/types'; import type { McpServerConfig } from '#/mcpCore/config-schema'; diff --git a/packages/agent-core-v2/src/features/cron/cronAgentRuntime.ts b/packages/agent-core-v2/src/features/cron/cronAgentRuntime.ts index 47387a369..4c0806325 100644 --- a/packages/agent-core-v2/src/features/cron/cronAgentRuntime.ts +++ b/packages/agent-core-v2/src/features/cron/cronAgentRuntime.ts @@ -22,7 +22,7 @@ import { jitteredNextCronRunMs, oneShotJitteredNextCronRunMs } from '#/features/ import type { CronDeletedEvent, CronScheduledEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { BugIndicatingError } from '#/errors'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#/llm-adapter/contract/message'; import { IAgentReminderService } from '#/features/reminder/reminderService'; import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { Forked } from '#/session/agentLifecycle/forked'; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/dsml-tool-parser.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/dsml-tool-parser.ts similarity index 99% rename from packages/agent-core-v2/src/kosong/provider/bases/openai/dsml-tool-parser.ts rename to packages/agent-core-v2/src/human/llm/requester/bases/openai/dsml-tool-parser.ts index 7e7bc42e4..26416f1dc 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/dsml-tool-parser.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/dsml-tool-parser.ts @@ -1,4 +1,4 @@ -import type { StreamedMessagePart, ToolCall } from '#/kosong/contract/message'; +import type { StreamedMessagePart, ToolCall } from '#/llm/message'; const MARK = String.raw`\s*[||]?\s*(?:DSML\s*[||]?)?\s*`; const CONTAINER_OPEN_RE = new RegExp(String.raw`<${MARK}tool_calls\s*>`, 'yi'); diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/format.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/format.ts index d074e9c1f..8abaa71d0 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/openai/format.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/format.ts @@ -335,11 +335,21 @@ export function isContextOverflowErrorCode(code: string | null | undefined): boo return code === 'context_length_exceeded'; } +const BILLING_REJECTION_PHRASES = [ + 'insufficient balance', + 'insufficient credit', + 'credits exhausted', + 'please recharge', +]; + function isOpenAIInsufficientQuotaError(error: RawOpenAISDKAPIError): boolean { - if (error.status !== 429) return false; + if (![401, 402, 403, 429].includes(error.status ?? 0)) return false; if (typeof error.code === 'string' && isOpenAIInsufficientQuotaCode(error.code)) return true; if (typeof error.type === 'string' && isOpenAIInsufficientQuotaCode(error.type)) return true; - return error.message.toLowerCase().includes('insufficient_quota'); + const message = error.message.toLowerCase(); + if (message.includes('insufficient_quota')) return true; + if (error.status === 429) return false; + return BILLING_REJECTION_PHRASES.some((phrase) => message.includes(phrase)); } export function convertOpenAIError( @@ -367,7 +377,7 @@ export function convertOpenAIError( return { kind: 'quota_exhausted', message: sanitizeStatusErrorMessage(error.message), - statusCode: 429, + statusCode: error.status, requestId, retryAfterMs, headers, diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts index 70f631266..371ffe9e8 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts @@ -2,6 +2,8 @@ import OpenAI from 'openai'; import { assign, shake } from 'radashi'; import { headersToRecord } from '#/llm/errors'; +import type { FinishInfo } from '#/llm/finish-reason'; +import type { StreamedMessagePart, ToolCall } from '#/llm/message'; import { modelKey, type LlmModel } from '#/llm/model'; import { toLlmSyntaxErrorMessage } from '#/llm/syntax-errors'; import type { ProtocolBase, ProtocolRequesterOptions, TraitContext } from '#/llm/protocol/base'; @@ -43,6 +45,7 @@ import { type OpenAIRequestParams, } from './format'; import { DEFAULT_REASONING_KEY, ReasoningKeyDialect } from './reasoning-key'; +import { DsmlStreamParser } from './dsml-tool-parser'; const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { normalize: (id) => sanitizeToolCallId(id, 64), @@ -173,12 +176,34 @@ async function executeOpenAIRequest( }, }); let messageId: string | undefined; + const dsml = new DsmlStreamParser(); + const recoveredToolCalls: ToolCall[] = []; + let nativeToolCallsSeen = false; + let finish: FinishInfo | undefined; + const emitPart = (part: StreamedMessagePart): void => { + onEvent?.({ type: 'llm.streaming.part', part }); + }; + const emitTextParts = (parts: readonly StreamedMessagePart[]): void => { + for (const part of parts) { + if (part.type === 'function') recoveredToolCalls.push(part); + else emitPart(part); + } + }; for await (const chunk of stream) { reasoning.observe(chunk.choices?.[0]?.delta); let failed = false; parse(chunk, { - onDelta: (part) => onEvent?.({ type: 'llm.streaming.part', part }), - onFinish: (finish) => onEvent?.({ type: 'llm.streaming.finish', finish }), + onDelta: (part) => { + if (part.type === 'text') { + emitTextParts(dsml.feed(part.text)); + return; + } + if (part.type === 'function' || part.type === 'tool_call_part') nativeToolCallsSeen = true; + emitPart(part); + }, + onFinish: (next) => { + finish = next; + }, onMessageId: (id) => { if (id === messageId) return; messageId = id; @@ -194,6 +219,20 @@ async function executeOpenAIRequest( return; } } + emitTextParts(dsml.flush()); + const recovered = !nativeToolCallsSeen && recoveredToolCalls.length > 0; + if (recovered) { + for (const toolCall of recoveredToolCalls) emitPart(toolCall); + } + if (finish !== undefined) { + onEvent?.({ + type: 'llm.streaming.finish', + finish: + recovered && (finish.finishReason === 'completed' || finish.finishReason === null) + ? { ...finish, finishReason: 'tool_calls' } + : finish, + }); + } onEvent?.({ type: 'llm.done' }); } diff --git a/packages/agent-core-v2/src/human/test/llm/dsml-tool-parser-conformance.test.ts b/packages/agent-core-v2/src/human/test/llm/dsml-tool-parser-conformance.test.ts new file mode 100644 index 000000000..1e032e4f9 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/llm/dsml-tool-parser-conformance.test.ts @@ -0,0 +1,333 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import type { StreamedMessagePart } from '#/llm/message'; +import { + DSML_MAX_ENVELOPE_CHARS, + DSML_MAX_TAG_CHARS, + DsmlStreamParser, + extractDsmlToolCalls, + parseInvokeBody, +} from '#/llm/requester/bases/openai/dsml-tool-parser'; + +interface Normalized { + text: string; + calls: Array<{ name: string; arguments: string }>; +} + +function normalize(parts: StreamedMessagePart[]): Normalized { + const calls: Normalized['calls'] = []; + let text = ''; + for (const part of parts) { + if (part.type === 'function') calls.push({ name: part.name, arguments: part.arguments ?? '' }); + else if (part.type === 'text') text += part.text; + } + return { text, calls }; +} + +function runChunks(chunks: string[]): Normalized { + const parser = new DsmlStreamParser(); + const parts: StreamedMessagePart[] = []; + for (const chunk of chunks) parts.push(...parser.feed(chunk)); + parts.push(...parser.flush()); + return normalize(parts); +} + +function seeded(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x100000000; + }; +} + +function randomPartition(input: string, random: () => number): string[] { + const chunks: string[] = []; + let pos = 0; + while (pos < input.length) { + const size = 1 + Math.floor(random() * 12); + chunks.push(input.slice(pos, pos + size)); + pos += size; + } + return chunks; +} + +const DSML_CALL = + '<|DSML|tool_calls>\n<|DSML|invoke name="Read">\n<|DSML|parameter name="filePath" string="true">src/app.ts\n\n'; + +const CORPUS: Record = { + plainText: 'Check if 5 < 10 and 20 > 15, or use
Hello
and vector.', + singleCall: `Looking into the code...\n\n${DSML_CALL}\nDone reading.`, + asciiBars: + '<|DSML|tool_calls>\n<|DSML|invoke name="Glob">\n<|DSML|parameter name="pattern" string="true">**/*.ts\n\n', + spacedMarkers: + '< | DSML | invoke name="Read">\n< | DSML | parameter name="filePath">src/app.ts\n', + twoInvokes: + '<|DSML|tool_calls>\n<|DSML|invoke name="Search">\n<|DSML|parameter name="query" string="true">export function\n<|DSML|parameter name="limit" string="false">25\n\n<|DSML|invoke name="Read">\n<|DSML|parameter name="path">src/main.ts\n\n', + bareInvoke: + 'Checking directory:\n<|DSML|invoke name="ListDir">\n<|DSML|parameter name="dir" string="true">packages\n', + jsonBody: '<|DSML|invoke name="Eval">{"code":"a < b && c > d"}', + hermes: 'Sure.\n\n{"name": "Read", "arguments": {"filePath": "package.json"}}\n\n', + hermesUnclosed: '{"name": "Read"}', + malformedInvoke: 'before <|DSML|invoke>malformed content without name after', + malformedBody: 'x <|DSML|invoke name="noop">definitely not JSON or parameters y', + containerNoCalls: 'before not a call after', + containerOnlyText: '<|DSML|tool_calls>\nplain prose\n', + unclosedContainer: 'a <|DSML|tool_calls>\nb', + fencedDoc: + 'Example only. Do not execute:\n```xml\n<|DSML|invoke name="noop">{"value":"documentation"}\n```\nEnd.', + fencedThenReal: `Use this syntax:\n\`\`\`\n${DSML_CALL}\n\`\`\`\nNow for real:\n${DSML_CALL}`, + tildeFence: '~~~\n{"name":"Read","arguments":{}}\n~~~\n', + unmarkedInvokeOutsideContainer: + 'Do not execute:\n{"value":"documentation"}\n', + unmarkedInvokeInsideContainer: + '\n\na.ts\n\n', + entities: + '<|DSML|invoke name="Eval">\n<|DSML|parameter name="code" string="true">a && b < c\n', + codeComparison: 'if (x < 5 && y > 2) { return a ', + multibyte: `café ✓ ${DSML_CALL} \u65E5\u672C\u8A9E 😀 done`, + strayClose: 'no container here still text', + emptyInvoke: '<|DSML|invoke name="Noop">', + whitespaceInvoke: '<|DSML|invoke name="Noop">\n \n', +}; + +describe('DsmlStreamParser conformance', () => { + describe('chunk invariance', () => { + for (const [name, input] of Object.entries(CORPUS)) { + it(`${name}: every two-way split equals the unsplit result`, () => { + const whole = runChunks([input]); + for (let offset = 1; offset < input.length; offset += 1) { + const split = runChunks([input.slice(0, offset), input.slice(offset)]); + expect(split, `split at ${offset}`).toEqual(whole); + } + }); + + it(`${name}: character-at-a-time equals the unsplit result`, () => { + const whole = runChunks([input]); + expect(runChunks(Array.from(input))).toEqual(whole); + }); + + it(`${name}: seeded random partitions equal the unsplit result`, () => { + const whole = runChunks([input]); + const random = seeded(name.length * 7919 + input.length); + for (let round = 0; round < 25; round += 1) { + const chunks = randomPartition(input, random); + expect(runChunks(chunks), chunks.join('|')).toEqual(whole); + } + }); + } + + it('handles a UTF-16 surrogate pair split between chunks', () => { + const input = `😀 ${DSML_CALL} 😀`; + const whole = runChunks([input]); + expect(runChunks([input.slice(0, 1), input.slice(1)])).toEqual(whole); + expect(runChunks([input.slice(0, input.length - 1), input.slice(-1)])).toEqual(whole); + }); + }); + + describe('streaming and non-streaming parity', () => { + for (const [name, input] of Object.entries(CORPUS)) { + it(`${name}: streamed text matches extractDsmlToolCalls`, () => { + const streamed = runChunks(Array.from(input)); + const extracted = extractDsmlToolCalls(input); + expect(extracted.toolCalls.map((c) => ({ name: c.name, arguments: c.arguments }))).toEqual( + streamed.calls, + ); + if (streamed.calls.length === 0) { + expect(extracted.cleanText).toBe(input); + expect(streamed.text).toBe(input); + } else { + expect(extracted.cleanText).toBe(streamed.text.trim()); + } + }); + } + }); + + describe('literal documentation stays text', () => { + it('does not promote a fenced DSML example to a tool call', () => { + const result = extractDsmlToolCalls(CORPUS['fencedDoc'] as string); + expect(result.toolCalls).toHaveLength(0); + expect(result.cleanText).toBe(CORPUS['fencedDoc']); + }); + + it('does not promote a fenced Hermes example to a tool call', () => { + const result = extractDsmlToolCalls(CORPUS['tildeFence'] as string); + expect(result.toolCalls).toHaveLength(0); + expect(result.cleanText).toBe(CORPUS['tildeFence']); + }); + + it('does not promote an unmarked invoke outside a container', () => { + const result = extractDsmlToolCalls(CORPUS['unmarkedInvokeOutsideContainer'] as string); + expect(result.toolCalls).toHaveLength(0); + expect(result.cleanText).toBe(CORPUS['unmarkedInvokeOutsideContainer']); + }); + + it('accepts an unmarked invoke inside a container', () => { + const result = extractDsmlToolCalls(CORPUS['unmarkedInvokeInsideContainer'] as string); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]?.name).toBe('Read'); + expect(result.cleanText).toBe(''); + }); + + it('parses the real call that follows a fenced example', () => { + const result = extractDsmlToolCalls(CORPUS['fencedThenReal'] as string); + expect(result.toolCalls).toHaveLength(1); + expect(result.cleanText).toBe(`Use this syntax:\n\`\`\`\n${DSML_CALL}\n\`\`\`\nNow for real:`); + }); + + it('resumes recognition after the fence closes', () => { + const input = '```\n{"name":"A"}\n```\n{"name":"B","arguments":{}}'; + const result = extractDsmlToolCalls(input); + expect(result.toolCalls.map((c) => c.name)).toEqual(['B']); + }); + }); + + describe('invoke body validation', () => { + it('rejects malformed nonempty bodies instead of producing empty arguments', () => { + const result = extractDsmlToolCalls(CORPUS['malformedBody'] as string); + expect(result.toolCalls).toHaveLength(0); + expect(result.cleanText).toBe(CORPUS['malformedBody']); + }); + + it('accepts an empty body as empty arguments', () => { + expect(extractDsmlToolCalls(CORPUS['emptyInvoke'] as string).toolCalls[0]?.arguments).toBe('{}'); + expect(extractDsmlToolCalls(CORPUS['whitespaceInvoke'] as string).toolCalls[0]?.arguments).toBe( + '{}', + ); + }); + + it('rejects prose mixed between parameter blocks', () => { + const input = + '<|DSML|invoke name="Read">\n<|DSML|parameter name="a">1\nstray prose\n'; + expect(extractDsmlToolCalls(input).toolCalls).toHaveLength(0); + }); + + it('rejects unnamed parameters, unclosed parameters, arrays and scalars', () => { + expect(parseInvokeBody('1')).toBeNull(); + expect(parseInvokeBody('1')).toBeNull(); + expect(parseInvokeBody('[1,2]')).toBeNull(); + expect(parseInvokeBody('42')).toBeNull(); + expect(parseInvokeBody('{"a":')).toBeNull(); + }); + + it('lets the last duplicate parameter win', () => { + const body = '12'; + expect(parseInvokeBody(body)).toEqual({ a: 2 }); + }); + + it('preserves reserved keys as own properties without touching the global prototype', () => { + const input = + '<|DSML|invoke name="noop"><|DSML|parameter name="__proto__" string="false">{"value":"kept"}<|DSML|parameter name="constructor">c<|DSML|parameter name="prototype">p<|DSML|parameter name="normal">ok'; + const result = extractDsmlToolCalls(input); + expect(result.toolCalls).toHaveLength(1); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual( + JSON.parse('{"__proto__":{"value":"kept"},"constructor":"c","prototype":"p","normal":"ok"}'), + ); + expect(result.toolCalls[0]?.arguments).toContain('"__proto__":{"value":"kept"}'); + expect(({} as Record)['value']).toBeUndefined(); + }); + + it('keeps reserved keys from JSON bodies', () => { + const args = parseInvokeBody('{"__proto__":{"x":1},"y":2}'); + expect(JSON.stringify(args)).toBe('{"__proto__":{"x":1},"y":2}'); + }); + }); + + describe('text preservation', () => { + it('keeps container tags when the container yields no call', () => { + const streamed = runChunks([CORPUS['containerNoCalls'] as string]); + expect(streamed).toEqual({ text: CORPUS['containerNoCalls'], calls: [] }); + }); + + it('drops container tags once a call is accepted and keeps later text', () => { + const streamed = runChunks([CORPUS['singleCall'] as string]); + expect(streamed.text).toBe('Looking into the code...\n\nDone reading.'); + expect(streamed.calls).toEqual([{ name: 'Read', arguments: '{"filePath":"src/app.ts"}' }]); + }); + + it('emits held text in order when a container has an invalid block then a valid call', () => { + const input = `<|DSML|tool_calls>\n<|DSML|invoke>bad\n<|DSML|invoke name="Ok">\n`; + const parser = new DsmlStreamParser(); + const parts = [...parser.feed(input), ...parser.flush()]; + expect(parts.map((p) => p.type)).toEqual(['text', 'text', 'function']); + expect(normalize(parts).text).toBe('<|DSML|invoke>bad\n'); + }); + + it('preserves an unclosed container at flush', () => { + expect(runChunks([CORPUS['unclosedContainer'] as string])).toEqual({ + text: CORPUS['unclosedContainer'], + calls: [], + }); + }); + }); + + describe('resource budgets', () => { + it('stops holding a tag prefix beyond the tag budget', () => { + const input = ` { + const body = 'x'.repeat(DSML_MAX_ENVELOPE_CHARS + 16); + const input = `<|DSML|invoke name="Big">${body}`; + const whole = runChunks([input]); + expect(whole.calls).toHaveLength(0); + expect(whole.text).toBe(input); + const random = seeded(7); + const chunks: string[] = []; + let pos = 0; + while (pos < input.length) { + const size = 1 + Math.floor(random() * 70000); + chunks.push(input.slice(pos, pos + size)); + pos += size; + } + expect(runChunks(chunks)).toEqual(whole); + }); + + it('accepts an envelope exactly at the budget', () => { + const open = '<|DSML|invoke name="Big">'; + const close = ''; + const body = 'x'.repeat(DSML_MAX_ENVELOPE_CHARS - open.length - close.length); + const input = `${open}${body}${close}`; + expect(input.length).toBe(DSML_MAX_ENVELOPE_CHARS); + expect(runChunks([input]).calls).toHaveLength(0); + const jsonBody = `{"v":"${'x'.repeat(DSML_MAX_ENVELOPE_CHARS - open.length - close.length - 8)}"}`; + const valid = `${open}${jsonBody}${close}`; + expect(valid.length).toBe(DSML_MAX_ENVELOPE_CHARS); + expect(runChunks([valid]).calls).toHaveLength(1); + expect(runChunks([valid.slice(0, 5000), valid.slice(5000)]).calls).toHaveLength(1); + }); + + it('scans an unclosed envelope in linear time', () => { + const parser = new DsmlStreamParser(); + parser.feed('<|DSML|invoke name="Big">'); + const chunk = 'y'.repeat(64); + const rounds = Math.floor((DSML_MAX_ENVELOPE_CHARS - 64) / 64); + const started = performance.now(); + for (let i = 0; i < rounds; i += 1) parser.feed(chunk); + const elapsed = performance.now() - started; + expect(elapsed).toBeLessThan(2000); + expect(normalize(parser.flush()).calls).toHaveLength(0); + }); + }); + + describe('implementation parity with @pymodel/kosong', () => { + it('keeps both parser copies byte-identical except for the message import', () => { + const dir = import.meta.dirname; + const here = resolve(dir, '../../llm/requester/bases/openai/dsml-tool-parser.ts'); + const legacy = resolve(dir, '../../../../../kosong/src/providers/dsml-tool-parser.ts'); + const v2 = readFileSync(here, 'utf8').replace( + "from '#/llm/message';", + "from '#/message';", + ); + expect(readFileSync(legacy, 'utf8')).toBe(v2); + }); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/llm/dsml-tool-parser.test.ts b/packages/agent-core-v2/src/human/test/llm/dsml-tool-parser.test.ts new file mode 100644 index 000000000..130113c57 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/llm/dsml-tool-parser.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from 'vitest'; + +import { + DsmlStreamParser, + extractDsmlToolCalls, +} from '#/llm/requester/bases/openai/dsml-tool-parser'; +import { UNKNOWN_CAPABILITY } from '#/llm/capability'; +import { createUserMessage } from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import { createOpenAIRequester } from '#/llm/requester/bases/openai/requester'; +import type { LlmRequestEvent } from '#/llm/requester/requester'; + +const model: LlmModel = { + provider: 'test', + model: 'deepseek-chat', + capability: UNKNOWN_CAPABILITY, + baseUrl: 'https://example.test/v1', +}; + +describe('agent-core-v2: DsmlStreamParser and extractDsmlToolCalls', () => { + describe('extractDsmlToolCalls', () => { + it('extracts standard DeepSeek DSML tool calls with fullwidth bars', () => { + const input = `I will read the file. +<|DSML|tool_calls> +<|DSML|invoke name="Read"> +<|DSML|parameter name="filePath" string="true">src/index.ts + +`; + + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe('I will read the file.'); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]?.name).toBe('Read'); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + filePath: 'src/index.ts', + }); + expect(result.toolCalls[0]?.id).toMatch(/^call_/); + }); + + it('extracts DSML tool calls with standard ASCII pipes', () => { + const input = `<|DSML|tool_calls> +<|DSML|invoke name="Glob"> +<|DSML|parameter name="pattern" string="true">**/*.ts + +`; + + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(''); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]?.name).toBe('Glob'); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + pattern: '**/*.ts', + }); + }); + + it('extracts multiple invokes with mixed typed parameters', () => { + const input = `<|DSML|tool_calls> +<|DSML|invoke name="Search"> +<|DSML|parameter name="query" string="true">export function +<|DSML|parameter name="limit" string="false">25 +<|DSML|parameter name="caseSensitive" string="false">true +<|DSML|parameter name="filter" string="false">{"type": "code"} + +<|DSML|invoke name="Read"> +<|DSML|parameter name="path">src/main.ts + +`; + + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(''); + expect(result.toolCalls).toHaveLength(2); + expect(result.toolCalls[0]?.name).toBe('Search'); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + query: 'export function', + limit: 25, + caseSensitive: true, + filter: { type: 'code' }, + }); + expect(result.toolCalls[1]?.name).toBe('Read'); + expect(JSON.parse(result.toolCalls[1]?.arguments ?? '{}')).toEqual({ + path: 'src/main.ts', + }); + }); + + it('extracts invoke without container tag', () => { + const input = `Checking directory: +<|DSML|invoke name="ListDir"> +<|DSML|parameter name="dir" string="true">packages +`; + + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe('Checking directory:'); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]?.name).toBe('ListDir'); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + dir: 'packages', + }); + }); + + it('extracts Hermes tool_call JSON format', () => { + const input = ` +{"name": "Read", "arguments": {"filePath": "package.json"}} +`; + + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(''); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]?.name).toBe('Read'); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + filePath: 'package.json', + }); + }); + + it('decodes XML entities in parameter values', () => { + const input = `<|DSML|invoke name="Eval"> +<|DSML|parameter name="code" string="true">a && b < c +`; + + const result = extractDsmlToolCalls(input); + expect(result.toolCalls).toHaveLength(1); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + code: 'a && b < c', + }); + }); + + it('preserves regular non-tool tags and operators in text', () => { + const input = 'Check if 5 < 10 and 20 > 15, or use
Hello
and vector.'; + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(input); + expect(result.toolCalls).toHaveLength(0); + }); + }); + + describe('DsmlStreamParser', () => { + it('streams normal text without modification', () => { + const parser = new DsmlStreamParser(); + const parts = [ + ...parser.feed('Hello world! '), + ...parser.feed('How are you today?'), + ...parser.flush(), + ]; + + expect(parts).toEqual([ + { type: 'text', text: 'Hello world! ' }, + { type: 'text', text: 'How are you today?' }, + ]); + expect(parser.hasExtractedToolCalls).toBe(false); + }); + + it('handles stream split across DSML container and invoke chunks', () => { + const parser = new DsmlStreamParser(); + const chunks = [ + 'Looking into the code...\n\n', + '<', + '|DSML', + '|tool_calls>\n', + '<|DSML|invoke name="Read">\n', + '<|DSML|parameter name="filePath" ', + 'string="true">src/app.ts', + '\n', + '\n', + '\n', + 'Done reading.', + ]; + + const parts = []; + for (const chunk of chunks) { + parts.push(...parser.feed(chunk)); + } + parts.push(...parser.flush()); + + expect(parser.hasExtractedToolCalls).toBe(true); + + const textParts = parts.filter((p) => p.type === 'text'); + const toolParts = parts.filter((p) => p.type === 'function'); + + expect(textParts.map((p) => p.text).join('')).toBe( + 'Looking into the code...\n\nDone reading.', + ); + expect(toolParts).toHaveLength(1); + expect(toolParts[0]?.name).toBe('Read'); + expect(JSON.parse(toolParts[0]?.arguments ?? '{}')).toEqual({ + filePath: 'src/app.ts', + }); + }); + + it('correctly flushes partial code comparisons that look like tags', () => { + const parser = new DsmlStreamParser(); + const parts = [ + ...parser.feed('if (x <'), + ...parser.feed(' 5 && y > 2)'), + ...parser.flush(), + ]; + + const fullText = parts.filter((p) => p.type === 'text').map((p) => p.text).join(''); + expect(fullText).toBe('if (x < 5 && y > 2)'); + expect(parser.hasExtractedToolCalls).toBe(false); + }); + }); + + describe('OpenAI requester DSML integration', () => { + function streamingClient(chunks: readonly unknown[]) { + async function* stream() { + for (const chunk of chunks) yield chunk; + } + return () => + ({ + chat: { + completions: { + create: () => ({ + withResponse: async () => ({ data: stream(), response: { headers: new Headers() } }), + }), + }, + }, + }) as never; + } + + async function run(chunks: readonly unknown[]): Promise { + const events: LlmRequestEvent[] = []; + await createOpenAIRequester({ clientFactory: streamingClient(chunks) }).generate( + { model }, + { messages: [createUserMessage('hi')] }, + { signal: new AbortController().signal, onEvent: (event) => events.push(event) }, + ); + return events; + } + + const dsmlContent = + 'Reading the code.\n\n<|DSML|tool_calls>\n<|DSML|invoke name="Read">\n<|DSML|parameter name="filePath" string="true">src/server.ts\n\n'; + + it('recovers streamed DSML tool calls from delta.content and reports tool_calls', async () => { + const events = await run([ + { id: 'c1', choices: [{ index: 0, delta: { content: dsmlContent }, finish_reason: 'stop' }] }, + ]); + const parts = events.flatMap((event) => (event.type === 'llm.streaming.part' ? [event.part] : [])); + expect(parts).toEqual([ + { type: 'text', text: 'Reading the code.\n\n' }, + expect.objectContaining({ type: 'function', name: 'Read', arguments: '{"filePath":"src/server.ts"}' }), + ]); + const finish = events.find((event) => event.type === 'llm.streaming.finish'); + expect(finish).toMatchObject({ finish: { finishReason: 'tool_calls' } }); + expect(events.at(-1)).toEqual({ type: 'llm.done' }); + }); + + it('keeps native tool calls and drops DSML recovery when the model sends both', async () => { + const events = await run([ + { id: 'c1', choices: [{ index: 0, delta: { content: dsmlContent }, finish_reason: null }] }, + { + id: 'c1', + choices: [ + { + index: 0, + delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'Bash', arguments: '{}' } }] }, + finish_reason: 'tool_calls', + }, + ], + }, + ]); + const calls = events.flatMap((event) => + event.type === 'llm.streaming.part' && event.part.type === 'function' ? [event.part.name] : [], + ); + expect(calls).toEqual(['Bash']); + }); + }); +}); diff --git a/packages/agent-core-v2/src/kosong/contract/capability.ts b/packages/agent-core-v2/src/kosong/contract/capability.ts deleted file mode 100644 index ef6a39df6..000000000 --- a/packages/agent-core-v2/src/kosong/contract/capability.ts +++ /dev/null @@ -1,44 +0,0 @@ -export interface ModelCapability { - readonly image_in: boolean; - readonly video_in: boolean; - readonly audio_in: boolean; - readonly thinking: boolean; - readonly tool_use: boolean; - readonly max_context_tokens: number; - readonly max_input_tokens?: number; - readonly dynamically_loaded_tools?: boolean; -} - -const UNKNOWN_CAPABILITY_MARKER = Symbol.for('pymodel.kosong.UNKNOWN_CAPABILITY'); - -export const UNKNOWN_CAPABILITY: ModelCapability = Object.freeze( - Object.defineProperty( - { - image_in: false, - video_in: false, - audio_in: false, - thinking: false, - tool_use: false, - max_context_tokens: 0, - dynamically_loaded_tools: false, - }, - UNKNOWN_CAPABILITY_MARKER, - { value: true }, - ), -); - -export function isUnknownCapability(capability: ModelCapability): boolean { - if (capability === UNKNOWN_CAPABILITY) return true; - const marked = - (capability as unknown as Record)[UNKNOWN_CAPABILITY_MARKER] === true; - if (marked) return true; - return ( - !capability.image_in && - !capability.video_in && - !capability.audio_in && - !capability.thinking && - !capability.tool_use && - capability.dynamically_loaded_tools !== true && - capability.max_context_tokens === 0 - ); -} diff --git a/packages/agent-core-v2/src/kosong/contract/errors.ts b/packages/agent-core-v2/src/kosong/contract/errors.ts deleted file mode 100644 index 5d3dfcc90..000000000 --- a/packages/agent-core-v2/src/kosong/contract/errors.ts +++ /dev/null @@ -1,511 +0,0 @@ -import { Error2, type Error2Options } from '#/_base/errors/errors'; -import type { FinishReason } from './provider'; - -export const CONFIG_INVALID_ERROR_CODE = 'config.invalid'; - -export const PROVIDER_API_ERROR_CODE = 'provider.api_error'; -export const PROVIDER_FILTERED_ERROR_CODE = 'provider.filtered'; -export const PROVIDER_RATE_LIMIT_ERROR_CODE = 'provider.rate_limit'; -export const PROVIDER_AUTH_ERROR_CODE = 'provider.auth_error'; -export const PROVIDER_CONNECTION_ERROR_CODE = 'provider.connection_error'; -export const PROVIDER_OVERLOADED_ERROR_CODE = 'provider.overloaded'; -export const CONTEXT_OVERFLOW_ERROR_CODE = 'context.overflow'; - -export type ProviderErrorCode = - | typeof PROVIDER_API_ERROR_CODE - | typeof PROVIDER_FILTERED_ERROR_CODE - | typeof PROVIDER_RATE_LIMIT_ERROR_CODE - | typeof PROVIDER_AUTH_ERROR_CODE - | typeof PROVIDER_CONNECTION_ERROR_CODE - | typeof PROVIDER_OVERLOADED_ERROR_CODE - | typeof CONTEXT_OVERFLOW_ERROR_CODE; - -export function sanitizeStatusErrorMessage(message: string): string { - const titleMatch = /]*>([\s\S]*?)<\/title>/i.exec(message); - const extracted = titleMatch?.[1]?.trim(); - const normalized = extracted !== undefined && extracted.length > 0 ? extracted : message; - return normalized.replaceAll('\r', ''); -} - -function codeForStatusError(statusCode: number): ProviderErrorCode { - if (statusCode === 429) return PROVIDER_RATE_LIMIT_ERROR_CODE; - if (statusCode === 401 || statusCode === 403) return PROVIDER_AUTH_ERROR_CODE; - if (statusCode === 529) return PROVIDER_OVERLOADED_ERROR_CODE; - return PROVIDER_API_ERROR_CODE; -} - -export class ChatProviderError extends Error2 { - constructor( - message: string, - code: ProviderErrorCode = PROVIDER_API_ERROR_CODE, - options?: Error2Options, - ) { - super(code, message, { ...options, name: 'ChatProviderError' }); - } -} - -export class APIConnectionError extends ChatProviderError { - constructor(message: string) { - super(message, PROVIDER_CONNECTION_ERROR_CODE); - this.name = 'APIConnectionError'; - } -} - -export class VideoUploadUnsupportedError extends ChatProviderError { - constructor(message: string) { - super(message); - this.name = 'VideoUploadUnsupportedError'; - } -} - -export class APITimeoutError extends ChatProviderError { - constructor(message: string) { - super(message, PROVIDER_CONNECTION_ERROR_CODE); - this.name = 'APITimeoutError'; - } -} - -export class APIStatusError extends ChatProviderError { - readonly statusCode: number; - readonly requestId: string | null; - readonly retryAfterMs: number | null; - readonly traceId: string | null; - - constructor( - statusCode: number, - message: string, - requestId?: string | null, - retryAfterMs?: number | null, - traceId?: string | null, - code: ProviderErrorCode = codeForStatusError(statusCode), - ) { - super(sanitizeStatusErrorMessage(message), code, { - details: { statusCode, requestId: requestId ?? null, traceId: traceId ?? null }, - }); - this.name = 'APIStatusError'; - this.statusCode = statusCode; - this.requestId = requestId ?? null; - this.retryAfterMs = retryAfterMs ?? null; - this.traceId = traceId ?? null; - } -} - -export class APIContextOverflowError extends APIStatusError { - constructor( - statusCode: number, - message: string, - requestId?: string | null, - retryAfterMs?: number | null, - traceId?: string | null, - ) { - super(statusCode, message, requestId, retryAfterMs, traceId, CONTEXT_OVERFLOW_ERROR_CODE); - this.name = 'APIContextOverflowError'; - } -} - -export class APIRequestTooLargeError extends APIStatusError { - constructor( - statusCode: number, - message: string, - requestId?: string | null, - retryAfterMs?: number | null, - traceId?: string | null, - ) { - super(statusCode, message, requestId, retryAfterMs, traceId); - this.name = 'APIRequestTooLargeError'; - } -} - -export class APIProviderRateLimitError extends APIStatusError { - constructor( - message: string, - requestId?: string | null, - retryAfterMs?: number | null, - traceId?: string | null, - ) { - super(429, message, requestId, retryAfterMs, traceId); - this.name = 'APIProviderRateLimitError'; - } -} - -export class APIProviderQuotaExhaustedError extends APIStatusError { - constructor( - message: string, - requestId?: string | null, - retryAfterMs?: number | null, - traceId?: string | null, - ) { - super(429, message, requestId, retryAfterMs, traceId, PROVIDER_API_ERROR_CODE); - this.name = 'APIProviderQuotaExhaustedError'; - } -} - -export class APIProviderOverloadedError extends APIStatusError { - constructor( - statusCode: number, - message: string, - requestId?: string | null, - retryAfterMs?: number | null, - traceId?: string | null, - ) { - super(statusCode, message, requestId, retryAfterMs, traceId, PROVIDER_OVERLOADED_ERROR_CODE); - this.name = 'APIProviderOverloadedError'; - } -} - -export class APIEmptyResponseError extends ChatProviderError { - readonly finishReason: FinishReason | null; - readonly rawFinishReason: string | null; - - constructor( - message: string, - options: { - readonly finishReason?: FinishReason | null; - readonly rawFinishReason?: string | null; - } = {}, - ) { - const finishReason = options.finishReason ?? null; - const rawFinishReason = options.rawFinishReason ?? null; - super( - message, - finishReason === 'filtered' ? PROVIDER_FILTERED_ERROR_CODE : PROVIDER_API_ERROR_CODE, - { details: { finishReason, rawFinishReason } }, - ); - this.name = 'APIEmptyResponseError'; - this.finishReason = finishReason; - this.rawFinishReason = rawFinishReason; - } -} - -export function createAbortError(): DOMException { - return new DOMException('The operation was aborted.', 'AbortError'); -} - -export function isAbortError(error: unknown): boolean { - if (error instanceof DOMException && error.name === 'AbortError') return true; - if (error instanceof Error && error.name === 'AbortError') return true; - return ( - typeof error === 'object' && - error !== null && - (error as object).constructor?.name === 'APIUserAbortError' - ); -} - -export function throwIfAbortError(error: unknown): void { - if (isAbortError(error)) { - throw createAbortError(); - } -} - -const IMAGE_FORMAT_PROVIDER_MESSAGE_PATTERNS = [ - /unsupported media type for base64 image/, - /invalid data url for image/, -] as const; - -const IMAGE_FORMAT_STATUS_MESSAGE_PATTERNS = [ - /unsupported image (?:url|format|type)/, - /does not represent a valid image/, - /could not (?:process|decode) (?:the |input )?image/, - /unable to process (?:the |input )?image/, - /failed to decode (?:the )?image/, - /invalid image(?: data| type| format)?/, -] as const; - -const MEDIA_TYPE_FIELD_PATTERN = /(?:media|mime)_?type/; - -export function isImageFormatError(error: unknown): boolean { - if (error instanceof APIStatusError) { - if (error instanceof APIContextOverflowError) return false; - if (error instanceof APIRequestTooLargeError) return false; - if (error.statusCode !== 400) return false; - const lowerMessage = error.message.toLowerCase(); - return ( - IMAGE_FORMAT_STATUS_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)) || - (MEDIA_TYPE_FIELD_PATTERN.test(lowerMessage) && lowerMessage.includes('image')) - ); - } - if (error instanceof ChatProviderError) { - const lowerMessage = error.message.toLowerCase(); - return IMAGE_FORMAT_PROVIDER_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); - } - return false; -} - -export function isRetryableGenerateError(error: unknown): boolean { - if (error instanceof APIConnectionError || error instanceof APITimeoutError) { - return true; - } - if (error instanceof APIEmptyResponseError) { - return error.finishReason !== 'filtered'; - } - if (error instanceof APIProviderOverloadedError) { - return true; - } - if (error instanceof APIStatusError) { - if (error instanceof APIProviderQuotaExhaustedError) { - return false; - } - return [408, 409, 429, 500, 502, 503, 504, 529].includes(error.statusCode); - } - return false; -} - -const NETWORK_RE = /network|connection|connect|disconnect|terminated/i; -const TIMEOUT_RE = /timed?\s*out|timeout|deadline/i; - -export function classifyBaseApiError(message: string): ChatProviderError { - if (TIMEOUT_RE.test(message)) { - return new APITimeoutError(message); - } - if (NETWORK_RE.test(message)) { - return new APIConnectionError(message); - } - const lowerMessage = message.toLowerCase(); - if (PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage))) { - return new APIProviderRateLimitError(message); - } - return new ChatProviderError(`Error: ${message}`); -} - -const CONTEXT_OVERFLOW_MESSAGE_PATTERNS = [ - /context[ _-]?length/, - /(?:context[ _-]?window.*exceed|exceed.*context[ _-]?window)/, - /maximum context/, - /exceed(?:ed|s|ing)?\s+(?:the\s+)?max(?:imum)?\s+tokens?/, - /(?:too many tokens.*(?:prompt|input|context)|(?:prompt|input|context).*too many tokens)/, - /prompt is too long.*maximum/, - /input token count.*exceeds?.*maximum number of tokens/, - /request.*exceed(?:ed|s|ing)?.*model token limit/, -] as const; - -const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [ - /(?:apistatuserror.*429|429.*apistatuserror)/, - /429.*too many requests/, - /too many requests/, - /provider\.rate_limit/, - /reached .*max rpm/, - /rate[ _-]?limit(?:ed)?/, - /rate-limited/, -] as const; - -const PROVIDER_OVERLOAD_MESSAGE_PATTERNS = [/overload/] as const; - -const REQUEST_TOO_LARGE_MESSAGE_PATTERNS = [ - /request exceeds the maximum size/, - /request entity too large/, - /request_too_large/, - /exceeds? the maximum allowed number of bytes/, - /payload too large/, - /content too large/, - /request (?:body )?too large/, -] as const; - -const THINKING_EFFORT_CONFIG_DOCS_URL = - 'https://code.pythinker.com/pythinker-code/en/configuration/config-files.html#thinking'; - -const THINKING_EFFORT_STATUS_MESSAGE_PATTERNS = [ - /reasoning[_ .-]?effort/, - /thinking[_ .-]?effort/, - /output_config[\s\S]*effort/, - /unsupported[\s\S]*effort/, - /invalid[\s\S]*effort/, -] as const; - -function appendThinkingEffortConfigHint(statusCode: number, message: string): string { - if (statusCode !== 400 && statusCode !== 422) return message; - const lowerMessage = message.toLowerCase(); - if (!THINKING_EFFORT_STATUS_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage))) { - return message; - } - if (message.includes(THINKING_EFFORT_CONFIG_DOCS_URL)) return message; - return `${message} - -The provider rejected the configured thinking effort. Non-Pythinker providers receive effort strings without client-side mapping; choose an effort supported by the selected model. For Pythinker models, check support_efforts and default_effort. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; -} - -export function isContextOverflowErrorCode(code: string | null | undefined): boolean { - return code === 'context_length_exceeded'; -} - -export function normalizeAPIStatusError( - statusCode: number, - message: string, - requestId?: string | null, - retryAfterMs?: number | null, - traceId?: string | null, -): APIStatusError { - if (statusCode === 429) { - return new APIProviderRateLimitError(message, requestId, retryAfterMs, traceId); - } - if (isContextOverflowStatusError(statusCode, message)) { - return new APIContextOverflowError(statusCode, message, requestId, retryAfterMs, traceId); - } - if (isRequestTooLargeStatusError(statusCode, message)) { - return new APIRequestTooLargeError(statusCode, message, requestId, retryAfterMs, traceId); - } - if (isProviderOverloadStatusError(statusCode, message)) { - return new APIProviderOverloadedError(statusCode, message, requestId, retryAfterMs, traceId); - } - return new APIStatusError( - statusCode, - appendThinkingEffortConfigHint(statusCode, message), - requestId, - retryAfterMs, - traceId, - ); -} - -export function parseRetryAfterMs(headers: unknown): number | null { - const raw = - headers !== null && - typeof headers === 'object' && - typeof (headers as { get?: unknown }).get === 'function' - ? (headers as { get(name: string): string | null }).get('retry-after') - : null; - if (raw === null || raw === undefined) return null; - const seconds = Number.parseInt(raw, 10); - if (!Number.isFinite(seconds) || seconds < 0) return null; - return seconds * 1000; -} - -export function parseTraceId(headers: unknown): string | null { - const raw = - headers !== null && - typeof headers === 'object' && - typeof (headers as { get?: unknown }).get === 'function' - ? (headers as { get(name: string): string | null }).get('x-trace-id') - : null; - if (raw === null || raw === undefined || raw.length === 0) return null; - return raw; -} - -export function isContextOverflowStatusError(statusCode: number, message: string): boolean { - if (statusCode !== 400 && statusCode !== 413 && statusCode !== 422) return false; - const lowerMessage = message.toLowerCase(); - return CONTEXT_OVERFLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); -} - -export function isProviderOverloadStatusError(statusCode: number, message: string): boolean { - if (statusCode === 529) return true; - if (statusCode !== 500 && statusCode !== 503) return false; - const lowerMessage = message.toLowerCase(); - return PROVIDER_OVERLOAD_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); -} - -export function isRequestTooLargeStatusError(statusCode: number, message: string): boolean { - if (statusCode !== 413) return false; - const lowerMessage = message.toLowerCase(); - return REQUEST_TOO_LARGE_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); -} - -const TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS = [ - /tool_use[\s\S]*tool_result/, - /tool_result[\s\S]*tool_use/, - /unexpected\s+`?tool_result/, - /tool_call_id[\s\S]*not found/, - /role\s+['"`]?tool['"`]?\s+must be a response to a preceding message/, - /assistant message with\s+['"`]?tool_calls['"`]?\s+must be followed by tool messages/, - /tool_call_ids? did not have response messages/, - /insufficient tool messages following/, -] as const; - -export function isToolExchangeAdjacencyError(error: unknown): boolean { - if (!(error instanceof APIStatusError)) return false; - if (error instanceof APIContextOverflowError) return false; - if (error.statusCode !== 400 && error.statusCode !== 422) return false; - const lowerMessage = error.message.toLowerCase(); - return TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); -} - -const STRUCTURAL_REQUEST_MESSAGE_PATTERNS = [ - /text content blocks must be non-empty/, - /text content blocks must contain non-whitespace/, - /first message must use the .*user.* role/, - /roles must alternate/, - /multiple .*(?:user|assistant).* roles in a row/, - /tool_use[\s\S]*ids must be unique/, - /message at position \d+ with role ['"`]?[a-z]+['"`]? must not be empty/, -] as const; - -export function isRecoverableRequestStructureError(error: unknown): boolean { - if (isToolExchangeAdjacencyError(error)) return true; - if (!(error instanceof APIStatusError)) return false; - if (error instanceof APIContextOverflowError) return false; - if (error.statusCode !== 400 && error.statusCode !== 422) return false; - const lowerMessage = error.message.toLowerCase(); - return STRUCTURAL_REQUEST_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); -} - -export function isProviderRateLimitError(error: unknown): boolean { - if (error instanceof APIProviderQuotaExhaustedError) return false; - if (error instanceof APIProviderRateLimitError) return true; - - const statusCode = getStatusCode(error); - if (statusCode !== undefined) return statusCode === 429; - - const lowerMessage = errorMessage(error).toLowerCase(); - return PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); -} - -function getStatusCode(error: unknown): number | undefined { - if (typeof error !== 'object' || error === null) return undefined; - - const record = error as Record; - const statusCode = record['statusCode']; - if (typeof statusCode === 'number') return statusCode; - const status = record['status']; - if (typeof status === 'number') return status; - - const response = record['response']; - if (typeof response !== 'object' || response === null) return undefined; - const responseRecord = response as Record; - const responseStatusCode = responseRecord['statusCode']; - if (typeof responseStatusCode === 'number') return responseStatusCode; - const responseStatus = responseRecord['status']; - return typeof responseStatus === 'number' ? responseStatus : undefined; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -export type ApiErrorKind = - | 'context_overflow' - | 'overloaded' - | 'rate_limit' - | 'quota_exhausted' - | 'auth' - | '5xx_server' - | '4xx_client' - | 'network' - | 'timeout' - | 'empty_response' - | 'other'; - -export interface ApiErrorClassification { - readonly kind: ApiErrorKind; - readonly statusCode?: number; -} - -export function classifyApiError(error: unknown): ApiErrorClassification { - const statusCode = getStatusCode(error); - if (error instanceof APIContextOverflowError) return { kind: 'context_overflow', statusCode }; - if (error instanceof APIProviderOverloadedError) return { kind: 'overloaded', statusCode }; - if (error instanceof APIProviderQuotaExhaustedError) { - return { kind: 'quota_exhausted', statusCode }; - } - if (error instanceof APIStatusError) { - if (isContextOverflowStatusError(error.statusCode, error.message)) { - return { kind: 'context_overflow', statusCode }; - } - if (error.statusCode === 429) return { kind: 'rate_limit', statusCode }; - if (error.statusCode === 529) return { kind: 'overloaded', statusCode }; - if (error.statusCode === 401 || error.statusCode === 403) return { kind: 'auth', statusCode }; - if (error.statusCode >= 500) return { kind: '5xx_server', statusCode }; - if (error.statusCode >= 400) return { kind: '4xx_client', statusCode }; - } - if (error instanceof APIConnectionError) return { kind: 'network', statusCode }; - if (error instanceof APITimeoutError) return { kind: 'timeout', statusCode }; - if (error instanceof APIEmptyResponseError) return { kind: 'empty_response', statusCode }; - return { kind: 'other', statusCode }; -} diff --git a/packages/agent-core-v2/src/kosong/contract/generate.ts b/packages/agent-core-v2/src/kosong/contract/generate.ts deleted file mode 100644 index dd667b95d..000000000 --- a/packages/agent-core-v2/src/kosong/contract/generate.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { performance, type EventLoopUtilization } from 'node:perf_hooks'; - -import { APIEmptyResponseError, createAbortError } from './errors'; -import { - isContentPart, - isToolCall, - isToolCallPart, - mergeInPlace, - type Message, - type StreamedMessagePart, - type ToolCall, -} from './message'; -import type { ChatProvider, FinishReason, GenerateOptions, StreamedMessage } from './provider'; -import type { Tool } from './tool'; -import type { TokenUsage } from './usage'; - -type StoredToolCall = Omit; - -export interface GenerateResult { - readonly id: string | null; - readonly message: Message; - readonly usage: TokenUsage | null; - readonly finishReason: FinishReason | null; - readonly rawFinishReason: string | null; - readonly traceId?: string | null; -} - -export interface GenerateCallbacks { - onMessagePart?: (part: StreamedMessagePart) => void | Promise; - onToolCall?: (toolCall: ToolCall) => void | Promise; -} - -export async function generate( - provider: ChatProvider, - systemPrompt: string, - tools: Tool[], - history: Message[], - callbacks?: GenerateCallbacks, - options?: GenerateOptions, -): Promise { - const message: Message = { role: 'assistant', content: [], toolCalls: [] }; - let pendingPart: StreamedMessagePart | null = null; - let deferredThink: StreamedMessagePart | null = null; - - const toolCallIndexMap = new Map(); - - if (options?.signal?.aborted) { - throw createAbortError(); - } - - const wireTools = tools.some((tool) => tool.deferred === true) - ? tools.filter((tool) => tool.deferred !== true) - : tools; - - options?.onRequestStart?.(); - const stream = await provider.generate(systemPrompt, wireTools, history, options); - if (stream.traceId !== undefined) { - options?.onTraceId?.(stream.traceId); - } - - await throwIfAborted(options?.signal, stream); - - let serverDecodeMs = 0; - let clientConsumeMs = 0; - let firstPartAt: number | undefined; - let lastResumeAt = 0; - let decodeEluStart: EventLoopUtilization | undefined; - - for await (const part of stream) { - const arrivedAt = Date.now(); - if (firstPartAt === undefined) { - firstPartAt = arrivedAt; - decodeEluStart = performance.eventLoopUtilization(); - } else { - serverDecodeMs += arrivedAt - lastResumeAt; - } - - try { - await throwIfAborted(options?.signal, stream); - - if (callbacks?.onMessagePart !== undefined) { - await callbacks.onMessagePart(deepCopyPart(part)); - await throwIfAborted(options?.signal, stream); - } - - if ( - isToolCallPart(part) && - part.index !== undefined && - !isPendingToolCallAtIndex(pendingPart, part.index) - ) { - const arrayIdx = toolCallIndexMap.get(part.index); - if (arrayIdx !== undefined) { - const target = message.toolCalls[arrayIdx]; - if (target !== undefined && part.argumentsPart !== null) { - target.arguments = - target.arguments === null - ? part.argumentsPart - : target.arguments + part.argumentsPart; - } - continue; - } - } - - if (part.type === 'text') deferredThink = null; - if (pendingPart === null) { - pendingPart = part; - } else if ( - pendingPart.type === 'text' && - part.type === 'think' && - part.encrypted === undefined && - part.think.trim().length === 0 - ) { - deferredThink = part; - } else if (!mergeInPlace(pendingPart, part)) { - flushPart(message, pendingPart, toolCallIndexMap); - if (deferredThink !== null) { - flushPart(message, deferredThink, toolCallIndexMap); - deferredThink = null; - } - pendingPart = part; - } - } finally { - lastResumeAt = Date.now(); - clientConsumeMs += lastResumeAt - arrivedAt; - } - } - - await throwIfAborted(options?.signal, stream); - if (firstPartAt !== undefined) { - serverDecodeMs += Date.now() - lastResumeAt; - } - const elu = - firstPartAt === undefined || decodeEluStart === undefined - ? undefined - : performance.eventLoopUtilization(decodeEluStart); - const clientBlockedMs = - elu === undefined ? undefined : Math.max(0, Math.round(elu.active) - clientConsumeMs); - options?.onStreamEnd?.( - firstPartAt === undefined ? undefined : { serverDecodeMs, clientConsumeMs, clientBlockedMs }, - ); - - if (pendingPart !== null) { - flushPart(message, pendingPart, toolCallIndexMap); - } - if (deferredThink !== null) { - flushPart(message, deferredThink, toolCallIndexMap); - } - if (message.content.length === 0 && message.toolCalls.length === 0) { - throw new APIEmptyResponseError( - 'The API returned an empty response (no content, no tool calls).' + - formatFinishReasonHint(stream) + - ` Provider: ${provider.name}, model: ${provider.modelName}`, - { - finishReason: stream.finishReason, - rawFinishReason: stream.rawFinishReason, - }, - ); - } - - const hasThink = message.content.some((p) => p.type === 'think'); - const hasText = message.content.some((p) => p.type === 'text' && p.text.trim().length > 0); - const hasToolCalls = message.toolCalls.length > 0; - - if (hasThink && !hasText && !hasToolCalls) { - throw new APIEmptyResponseError( - 'The API returned a response containing only thinking content ' + - 'without any text or tool calls. This usually indicates the ' + - 'stream was interrupted or the output token budget was exhausted ' + - 'during reasoning.' + - formatFinishReasonHint(stream) + - ` Provider: ${provider.name}, model: ${provider.modelName}`, - { - finishReason: stream.finishReason, - rawFinishReason: stream.rawFinishReason, - }, - ); - } - - if (callbacks?.onToolCall !== undefined) { - for (const toolCall of message.toolCalls) { - await throwIfAborted(options?.signal, stream); - await callbacks.onToolCall(toolCall); - } - } - - const result: GenerateResult = { - id: stream.id, - message, - usage: stream.usage, - finishReason: stream.finishReason, - rawFinishReason: stream.rawFinishReason, - }; - if (stream.traceId !== undefined) { - return { ...result, traceId: stream.traceId }; - } - return result; -} - -type CancelableStream = StreamedMessage & { - cancel?: () => unknown; - return?: () => unknown; -}; - -async function cancelStream(stream: StreamedMessage): Promise { - const cancelable = stream as CancelableStream; - - try { - await cancelable.cancel?.(); - } catch {} - - try { - await cancelable.return?.(); - } catch {} -} - -async function throwIfAborted(signal?: AbortSignal, stream?: StreamedMessage): Promise { - if (!signal?.aborted) { - return; - } - - if (stream !== undefined) { - await cancelStream(stream); - } - - throw createAbortError(); -} - -function isPendingToolCallAtIndex( - pending: StreamedMessagePart | null, - index: number | string, -): pending is ToolCall { - return pending !== null && isToolCall(pending) && pending._streamIndex === index; -} - -function flushPart( - message: Message, - part: StreamedMessagePart, - toolCallIndexMap: Map, -): void { - if (isContentPart(part)) { - message.content.push(part); - return; - } - if (isToolCall(part)) { - const streamIndex = part._streamIndex; - const stored: StoredToolCall = { - type: 'function', - id: part.id, - name: part.name, - arguments: part.arguments, - extras: part.extras, - }; - const ordinal = message.toolCalls.length; - message.toolCalls.push(stored as ToolCall); - if (streamIndex !== undefined) { - toolCallIndexMap.set(streamIndex, ordinal); - } - } -} - -function formatFinishReasonHint(stream: StreamedMessage): string { - if (stream.finishReason === null && stream.rawFinishReason === null) return ''; - - const raw = - stream.rawFinishReason === null ? '' : `, rawFinishReason=${stream.rawFinishReason}`; - const filteredHint = - stream.finishReason === 'filtered' - ? ' The provider filtered the response before visible output was emitted.' - : ''; - - return ` Provider stop details: finishReason=${stream.finishReason ?? 'unknown'}${raw}.${filteredHint}`; -} - -function deepCopyPart(part: StreamedMessagePart): StreamedMessagePart { - return structuredClone(part); -} diff --git a/packages/agent-core-v2/src/kosong/contract/inspection.ts b/packages/agent-core-v2/src/kosong/contract/inspection.ts deleted file mode 100644 index 0cf0916ad..000000000 --- a/packages/agent-core-v2/src/kosong/contract/inspection.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type InspectionSourceKind = - | 'config' - | 'override' - | 'builtin' - | 'env' - | 'synthesized' - | 'none'; - -export interface InspectionSource { - readonly kind: InspectionSourceKind; - readonly detail?: string; -} - -export interface ResolutionTrace { - record(path: string, source: InspectionSource): void; - capture(key: string, value: unknown): void; -} diff --git a/packages/agent-core-v2/src/kosong/contract/message.ts b/packages/agent-core-v2/src/kosong/contract/message.ts deleted file mode 100644 index 9c1cca28c..000000000 --- a/packages/agent-core-v2/src/kosong/contract/message.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { Tool } from './tool'; - -export type Role = 'system' | 'user' | 'assistant' | 'tool'; - -export interface TextPart { - type: 'text'; - text: string; -} - -export interface ThinkPart { - type: 'think'; - think: string; - encrypted?: string; -} - -export interface ImageURLPart { - type: 'image_url'; - imageUrl: { url: string; id?: string; name?: string }; -} - -export interface AudioURLPart { - type: 'audio_url'; - audioUrl: { url: string; id?: string }; -} - -export interface VideoURLPart { - type: 'video_url'; - videoUrl: { url: string; id?: string; name?: string }; -} - -export type ContentPart = TextPart | ThinkPart | ImageURLPart | AudioURLPart | VideoURLPart; - -export interface ToolCall { - type: 'function'; - id: string; - name: string; - arguments: string | null; - extras?: Record; - _streamIndex?: number | string; -} - -export interface ToolCallPart { - type: 'tool_call_part'; - argumentsPart: string | null; - index?: number | string; -} - -export type StreamedMessagePart = ContentPart | ToolCall | ToolCallPart; - -export interface Message { - readonly role: Role; - readonly name?: string; - readonly content: ContentPart[]; - readonly toolCalls: ToolCall[]; - readonly toolCallId?: string; - readonly partial?: boolean; - readonly tools?: readonly Tool[]; -} - -export function isContentPart(part: StreamedMessagePart): part is ContentPart { - const t = part.type; - return ( - t === 'text' || t === 'think' || t === 'image_url' || t === 'audio_url' || t === 'video_url' - ); -} - -export function isToolDeclarationOnlyMessage(message: Message): boolean { - return ( - message.tools !== undefined && - message.tools.length > 0 && - message.content.length === 0 && - message.toolCalls.length === 0 - ); -} - -export function isToolCall(part: StreamedMessagePart): part is ToolCall { - return part.type === 'function'; -} - -export function isToolCallPart(part: StreamedMessagePart): part is ToolCallPart { - return part.type === 'tool_call_part'; -} - -export function mergeInPlace(target: StreamedMessagePart, source: StreamedMessagePart): boolean { - if (target.type === 'text' && source.type === 'text') { - target.text += source.text; - return true; - } - - if (target.type === 'think' && source.type === 'think') { - if (target.encrypted !== undefined) { - return false; - } - target.think += source.think; - if (source.encrypted !== undefined) { - target.encrypted = source.encrypted; - } - return true; - } - - if (target.type === 'function' && source.type === 'tool_call_part') { - if (source.argumentsPart !== null) { - target.arguments = - target.arguments === null - ? source.argumentsPart - : target.arguments + source.argumentsPart; - } - return true; - } - - return false; -} - -export function extractText(message: Message, sep: string = ''): string { - return message.content - .filter((part): part is TextPart => part.type === 'text') - .map((part) => part.text) - .join(sep); -} - -export function getTextContent(message: Message): string { - return extractText(message); -} - -export function createUserMessage(content: string): Message { - return { - role: 'user', - content: [{ type: 'text', text: content }], - toolCalls: [], - }; -} - -export function createAssistantMessage(content: ContentPart[], toolCalls?: ToolCall[]): Message { - return { - role: 'assistant', - content, - toolCalls: toolCalls ?? [], - }; -} - -export function createToolMessage(toolCallId: string, output: string | ContentPart[]): Message { - const content: ContentPart[] = - typeof output === 'string' ? [{ type: 'text', text: output }] : output; - return { - role: 'tool', - content, - toolCalls: [], - toolCallId, - }; -} diff --git a/packages/agent-core-v2/src/kosong/contract/messageHelpers.ts b/packages/agent-core-v2/src/kosong/contract/messageHelpers.ts deleted file mode 100644 index c65713f77..000000000 --- a/packages/agent-core-v2/src/kosong/contract/messageHelpers.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { - createAssistantMessage, - createToolMessage, - createUserMessage, - extractText, - isContentPart, - isToolCall, - isToolCallPart, - isToolDeclarationOnlyMessage, - mergeInPlace, -} from './message'; diff --git a/packages/agent-core-v2/src/kosong/contract/provider.ts b/packages/agent-core-v2/src/kosong/contract/provider.ts deleted file mode 100644 index 3b4496000..000000000 --- a/packages/agent-core-v2/src/kosong/contract/provider.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { Message, StreamedMessagePart, VideoURLPart } from './message'; -import type { Tool } from './tool'; -import type { TokenUsage } from './usage'; - -export type ThinkingEffort = 'off' | 'on' | (string & {}); - -export type JsonSchemaObject = Record; - -export interface JsonObjectResponseFormat { - readonly type: 'json_object'; -} - -export interface JsonSchemaResponseFormat { - readonly type: 'json_schema'; - readonly jsonSchema: { - readonly name: string; - readonly schema: JsonSchemaObject; - readonly strict?: boolean; - readonly description?: string; - }; -} - -export type ResponseFormat = JsonObjectResponseFormat | JsonSchemaResponseFormat; - -export type FinishReason = - | 'completed' - | 'tool_calls' - | 'truncated' - | 'filtered' - | 'paused' - | 'other'; - -export interface StreamedMessage { - [Symbol.asyncIterator](): AsyncIterator; - readonly id: string | null; - readonly usage: TokenUsage | null; - readonly finishReason: FinishReason | null; - readonly rawFinishReason: string | null; - readonly traceId?: string | null; -} - -export interface ProviderRequestAuth { - apiKey?: string; - headers?: Record; -} - -export interface SamplingOptions { - readonly temperature?: number; - readonly topP?: number; -} - -export interface ThinkingRequestOptions { - readonly effort: ThinkingEffort; - readonly keep?: string; -} - -export interface ToolCallIdPolicy { - normalize: (id: string) => string; - maxLength?: number; -} - -export interface StreamDecodeStats { - readonly serverDecodeMs: number; - readonly clientConsumeMs: number; - readonly clientBlockedMs?: number; -} - -export interface VideoUploadInput { - readonly data: Uint8Array; - readonly mimeType: string; - readonly filename?: string | undefined; -} - -export interface GenerateOptions { - signal?: AbortSignal; - auth?: ProviderRequestAuth; - extraHeaders?: Readonly>; - responseFormat?: ResponseFormat; - cacheKey?: string; - sampling?: SamplingOptions; - thinking?: ThinkingRequestOptions; - maxCompletionTokens?: number; - usedContextTokens?: number; - maxContextTokens?: number; - onRequestStart?: () => void; - onRequestSent?: () => void; - onStreamEnd?: (stats?: StreamDecodeStats) => void; - onTraceId?: (traceId: string | null) => void; -} - -export interface ChatProvider { - readonly name: string; - readonly modelName: string; - readonly thinkingEffort: ThinkingEffort | null; - readonly maxCompletionTokens?: number; - generate( - systemPrompt: string, - tools: Tool[], - history: Message[], - options?: GenerateOptions, - ): Promise; - uploadVideo?(input: string | VideoUploadInput, options?: GenerateOptions): Promise; -} diff --git a/packages/agent-core-v2/src/kosong/contract/requestTrace.ts b/packages/agent-core-v2/src/kosong/contract/requestTrace.ts deleted file mode 100644 index fe60f0962..000000000 --- a/packages/agent-core-v2/src/kosong/contract/requestTrace.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface LLMRequestTrace { - readonly traceId: string | undefined; -} diff --git a/packages/agent-core-v2/src/kosong/contract/tokens.ts b/packages/agent-core-v2/src/kosong/contract/tokens.ts deleted file mode 100644 index 046a91ee7..000000000 --- a/packages/agent-core-v2/src/kosong/contract/tokens.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { ContentPart, Message } from './message'; -import type { Tool } from './tool'; - -const messageTokenEstimateCache = new WeakMap(); - -export function estimateTokens(text: string): number { - let asciiCount = 0; - let nonAsciiCount = 0; - for (const char of text) { - if (char.codePointAt(0)! <= 127) { - asciiCount++; - } else { - nonAsciiCount++; - } - } - return Math.ceil(asciiCount / 4) + nonAsciiCount; -} - -export function estimateTokensForMessages(messages: readonly Message[]): number { - let total = 0; - for (const message of messages) { - total += estimateTokensForMessage(message); - } - return total; -} - -export function estimateTokensForTools(tools: readonly Tool[]): number { - let total = 0; - for (const tool of tools) { - total += estimateTokens(tool.name); - total += estimateTokens(tool.description); - total += estimateTokens(JSON.stringify(tool.parameters)); - } - return total; -} - -export function estimateTokensForMessage(message: Message): number { - const cached = messageTokenEstimateCache.get(message); - if (cached !== undefined) { - return cached; - } - - let total = estimateTokens(message.role); - total += estimateTokensForContentParts(message.content); - if (message.toolCalls !== undefined) { - for (const call of message.toolCalls) { - total += estimateTokens(call.name); - total += estimateTokens(JSON.stringify(call.arguments)); - } - } - messageTokenEstimateCache.set(message, total); - return total; -} - -export function estimateTokensForContentParts(parts: readonly ContentPart[]): number { - let total = 0; - for (const part of parts) { - total += estimateTokensForContentPart(part); - } - return total; -} - -export const MEDIA_TOKEN_ESTIMATE = 2000; - -export function estimateTokensForContentPart(part: ContentPart): number { - switch (part.type) { - case 'text': - return estimateTokens(part.text); - case 'think': - return estimateTokens(part.think); - case 'image_url': - case 'audio_url': - case 'video_url': - return MEDIA_TOKEN_ESTIMATE; - default: { - const exhaustive: never = part; - void exhaustive; - return 0; - } - } -} diff --git a/packages/agent-core-v2/src/kosong/contract/tool.ts b/packages/agent-core-v2/src/kosong/contract/tool.ts deleted file mode 100644 index b070862b4..000000000 --- a/packages/agent-core-v2/src/kosong/contract/tool.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface Tool { - name: string; - description: string; - parameters: Record; - deferred?: true; -} diff --git a/packages/agent-core-v2/src/kosong/contract/usage.ts b/packages/agent-core-v2/src/kosong/contract/usage.ts deleted file mode 100644 index fee365174..000000000 --- a/packages/agent-core-v2/src/kosong/contract/usage.ts +++ /dev/null @@ -1,48 +0,0 @@ -export interface TokenUsage { - inputOther: number; - output: number; - inputCacheRead: number; - inputCacheCreation: number; -} - -export function usageDelta( - after: TokenUsage | undefined, - before: TokenUsage | undefined, -): TokenUsage | undefined { - if (after === undefined) return undefined; - return { - inputOther: Math.max(0, after.inputOther - (before?.inputOther ?? 0)), - output: Math.max(0, after.output - (before?.output ?? 0)), - inputCacheRead: Math.max(0, after.inputCacheRead - (before?.inputCacheRead ?? 0)), - inputCacheCreation: Math.max( - 0, - after.inputCacheCreation - (before?.inputCacheCreation ?? 0), - ), - }; -} - -export function inputTotal(usage: TokenUsage): number { - return usage.inputOther + usage.inputCacheRead + usage.inputCacheCreation; -} - -export function grandTotal(usage: TokenUsage): number { - return inputTotal(usage) + usage.output; -} - -export function emptyUsage(): TokenUsage { - return { - inputOther: 0, - output: 0, - inputCacheRead: 0, - inputCacheCreation: 0, - }; -} - -export function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage { - return { - inputOther: a.inputOther + b.inputOther, - output: a.output + b.output, - inputCacheRead: a.inputCacheRead + b.inputCacheRead, - inputCacheCreation: a.inputCacheCreation + b.inputCacheCreation, - }; -} diff --git a/packages/agent-core-v2/src/kosong/model/catalog.ts b/packages/agent-core-v2/src/kosong/model/catalog.ts deleted file mode 100644 index 1ebf394b9..000000000 --- a/packages/agent-core-v2/src/kosong/model/catalog.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { z } from 'zod'; - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { ProviderRequestAuth } from '#/kosong/contract/provider'; -import type { TokenUsage } from '#/kosong/contract/usage'; -import type { Protocol, ProtocolProviderOptions } from '#/kosong/protocol/protocol'; - -import type { ProviderConfig } from '../provider/provider'; - -import type { ModelInspection } from './inspection'; -import type { ModelRecord } from './model'; -import { effectiveModelConfig } from './modelAuth'; -import type { ModelRequester } from './modelRequester'; - -export interface AuthProvider { - readonly canRefresh?: boolean; - - getAuth(options?: { readonly force?: boolean }): Promise; -} - -export class StaticAuthProvider implements AuthProvider { - readonly canRefresh = false; - - constructor(private readonly apiKey: string | undefined) {} - async getAuth(): Promise { - if (this.apiKey === undefined || this.apiKey.trim().length === 0) return undefined; - return { apiKey: this.apiKey }; - } -} - -export interface Model { - readonly id: string; - readonly name: string; - readonly aliases: readonly string[]; - readonly protocol: Protocol; - readonly baseUrl?: string; - readonly headers: Readonly>; - - readonly capabilities: ModelCapability; - readonly maxContextSize: number; - readonly maxInputSize?: number; - readonly maxOutputSize?: number; - readonly displayName?: string; - readonly reasoningKey?: string; - readonly supportEfforts?: readonly string[]; - readonly defaultEffort?: string; - readonly alwaysThinking: boolean; - readonly providerType?: string; - readonly providerName: string; - - readonly authProvider: AuthProvider; - readonly providerOptions?: ProtocolProviderOptions; -} - -export interface ModelPingResult { - readonly ok: boolean; - readonly durationMs: number; - readonly text?: string; - readonly finishReason?: string; - readonly usage?: TokenUsage; - readonly error?: string; -} - -export const modelCatalogItemSchema = z.object({ - provider: z.string().min(1), - model: z.string().min(1), - display_name: z.string().min(1).optional(), - max_context_size: z.number().int().min(1), - capabilities: z.array(z.string()).optional(), - support_efforts: z.array(z.string()).optional(), - default_effort: z.string().optional(), -}); -export type ModelCatalogItem = z.infer; - -export const providerCatalogStatusSchema = z.enum([ - 'connected', - 'error', - 'unconfigured', -]); -export type ProviderCatalogStatus = z.infer; - -export const providerCatalogItemSchema = z.object({ - id: z.string().min(1), - type: z.string().min(1), - base_url: z.string().min(1).optional(), - default_model: z.string().min(1).optional(), - has_api_key: z.boolean(), - status: providerCatalogStatusSchema, - models: z.array(z.string().min(1)).optional(), -}); -export type ProviderCatalogItem = z.infer; - -export const setDefaultModelResponseSchema = z.object({ - default_model: z.string().min(1), - model: modelCatalogItemSchema, -}); -export type SetDefaultModelResponse = z.infer; - -export interface ProviderCredentialState { - readonly hasApiKey: boolean; - readonly hasOAuthToken: boolean; -} - -export function toProtocolModel( - model: Model, - record: ModelRecord, - providerType?: string, -): ModelCatalogItem { - return { - provider: model.providerName, - model: model.id, - display_name: model.displayName ?? model.name ?? model.id, - max_context_size: model.maxContextSize, - capabilities: effectiveModelConfig(record, providerType ?? model.providerType).capabilities, - support_efforts: model.supportEfforts === undefined ? undefined : [...model.supportEfforts], - default_effort: model.defaultEffort, - }; -} - -export function toProtocolModelFallback( - modelId: string, - record: ModelRecord, - providerType?: string, -): ModelCatalogItem { - const effective = effectiveModelConfig(record, providerType); - return { - provider: effective.provider ?? '', - model: modelId, - display_name: effective.displayName ?? effective.model ?? modelId, - max_context_size: effective.maxContextSize ?? 0, - capabilities: effective.capabilities, - support_efforts: effective.supportEfforts, - default_effort: effective.defaultEffort, - }; -} - -export function toProtocolProvider( - providerId: string, - provider: ProviderConfig, - models: Readonly>, - globalDefaultModel: string | undefined, - credential: ProviderCredentialState, -): ProviderCatalogItem { - const providerModels = modelIdsForProvider(models, providerId); - const defaultModel = - provider.defaultModel ?? globalDefaultForProvider(models, globalDefaultModel, providerId); - return { - id: providerId, - type: provider.type ?? 'openai', - base_url: provider.baseUrl, - default_model: defaultModel, - has_api_key: credential.hasApiKey, - status: credential.hasApiKey || credential.hasOAuthToken ? 'connected' : 'unconfigured', - models: providerModels, - }; -} - -export function modelIdsForProvider( - models: Readonly>, - providerId: string, -): string[] { - return Object.entries(models) - .filter(([, record]) => record.provider === providerId) - .map(([modelId]) => modelId); -} - -export function globalDefaultForProvider( - models: Readonly>, - globalDefaultModel: string | undefined, - providerId: string, -): string | undefined { - if (globalDefaultModel === undefined) return undefined; - const record = models[globalDefaultModel]; - return record?.provider === providerId ? globalDefaultModel : undefined; -} - -export interface IModelCatalog { - readonly _serviceBrand: undefined; - - get(id: string): Model; - getRequester(id: string): ModelRequester; - inspect(id: string): ModelInspection; - ping(id: string): Promise; - findByName(name: string): readonly string[]; - - listModels(): Promise; - listProviders(): Promise; - getProvider(providerId: string): Promise; - setDefaultModel(modelId: string): Promise; -} - -export const IModelCatalog: ServiceIdentifier = - createDecorator('modelResolver'); diff --git a/packages/agent-core-v2/src/kosong/model/catalogService.ts b/packages/agent-core-v2/src/kosong/model/catalogService.ts deleted file mode 100644 index 577e68fa8..000000000 --- a/packages/agent-core-v2/src/kosong/model/catalogService.ts +++ /dev/null @@ -1,644 +0,0 @@ -import { parsePythinkerCodeCustomHeaders } from '@pymodel/pythinker-code-oauth'; -import { randomUUID } from 'node:crypto'; - -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { Error2 } from '#/_base/errors/errors'; -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { ProviderRequestAuth } from '#/kosong/contract/provider'; -import type { TokenUsage } from '#/kosong/contract/usage'; -import { - IProtocolAdapterRegistry, - type Protocol, - type ProtocolProviderOptions, -} from '#/kosong/protocol/protocol'; - -import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors'; -import { - LATEST_OPUS_PROFILE, - matchKnownAnthropicModelProfile, - matchUnknownClaudeProfile, -} from '../provider/bases/anthropic/anthropic-profile'; -import { - IProviderService, - type ProviderConfig, -} from '../provider/provider'; -import { - explainProviderEndpoint, - getProviderDefinition, - resolveProviderEndpoint, -} from '../provider/providerDefinition'; - -import { - type AuthProvider, - IModelCatalog, - type Model, - type ModelCatalogItem, - type ModelPingResult, - type ProviderCatalogItem, - type ProviderCredentialState, - type SetDefaultModelResponse, - StaticAuthProvider, - toProtocolModel, - toProtocolModelFallback, - toProtocolProvider, -} from './catalog'; -import { ModelCatalogErrors } from './errors'; -import { IHostRequestHeaders } from './hostRequestHeaders'; -import { - assembleModelInspection, - attributeEffectiveFields, - attributeProviderOptions, - type ModelInspection, - ResolutionTraceCollector, - TRACE, -} from './inspection'; -import { IModelService, type ModelRecord } from './model'; -import { - deriveProviderId, - effectiveModelConfig, - nonEmpty, - resolveModelAuthMaterial, - resolveModelProtocol, -} from './modelAuth'; -import { IModelOAuthTokens } from './modelOAuth'; -import type { ResolvedModelAuthMaterial } from './model.types'; -import type { ModelRequester } from './modelRequester'; -import { ModelRequesterImpl } from './modelRequesterImpl'; -import { drivesThinkingThroughTraits } from './thinking'; - -type MutableProtocolProviderOptions = { - -readonly [K in keyof ProtocolProviderOptions]: ProtocolProviderOptions[K]; -}; - -interface CatalogEntry { - readonly model: Model; - readonly requester: ModelRequester; - readonly trace: ResolutionTraceCollector; -} - -export class ModelCatalog extends Disposable implements IModelCatalog { - declare readonly _serviceBrand: undefined; - - private readonly cache = new Map(); - - constructor( - @IProviderService private readonly providers: IProviderService, - @IModelService private readonly models: IModelService, - @IModelOAuthTokens private readonly oauth: IModelOAuthTokens, - @IProtocolAdapterRegistry - private readonly protocolRegistry: IProtocolAdapterRegistry, - @IHostRequestHeaders private readonly hostRequestHeaders: IHostRequestHeaders, - ) { - super(); - this._register(this.models.onDidChangeModels(() => this.notifyConfigChanged())); - this._register(this.providers.onDidChangeProviders(() => this.notifyConfigChanged())); - } - - notifyConfigChanged(): void { - this.cache.clear(); - } - - get(id: string): Model { - return this.entry(id).model; - } - - getRequester(id: string): ModelRequester { - return this.entry(id).requester; - } - - findByName(name: string): readonly string[] { - const out: string[] = []; - for (const [id, m] of Object.entries(this.models.list())) { - const alias = m.name === name || m.model === name || (m.aliases ?? []).includes(name); - if (alias) out.push(id); - } - return out; - } - - private entry(id: string): CatalogEntry { - const cached = this.cache.get(id); - if (cached !== undefined) return cached; - const trace = new ResolutionTraceCollector(); - const model = this.buildModel(id, trace); - const entry: CatalogEntry = { - model, - requester: new ModelRequesterImpl(model, this.protocolRegistry), - trace, - }; - this.cache.set(id, entry); - return entry; - } - - inspect(id: string): ModelInspection { - const { model, trace } = this.entry(id); - return assembleModelInspection({ id, model, trace }); - } - - async ping(id: string): Promise { - const { requester } = this.entry(id); - const startedAt = Date.now(); - try { - let text = ''; - let usage: TokenUsage | undefined; - let finishReason: string | undefined; - for await (const event of requester.request( - { - systemPrompt: 'You are a connectivity probe. Answer with the single word "pong".', - tools: [], - messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }], toolCalls: [] }], - }, - undefined, - { conversationId: randomUUID(), maxCompletionTokens: 512 }, - )) { - if (event.type === 'part' && event.part.type === 'text') { - text += event.part.text; - } else if (event.type === 'usage') { - usage = event.usage; - } else if (event.type === 'finish') { - finishReason = event.providerFinishReason ?? event.rawFinishReason; - } - } - return { ok: true, durationMs: Date.now() - startedAt, text: text.trim(), finishReason, usage }; - } catch (error) { - return { - ok: false, - durationMs: Date.now() - startedAt, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async listModels(): Promise { - const models = this.models.list(); - return Object.entries(models).map(([modelId, record]) => { - const providerType = this.providerTypeOf(record); - try { - return toProtocolModel(this.get(modelId), record, providerType); - } catch { - return toProtocolModelFallback(modelId, record, providerType); - } - }); - } - - async listProviders(): Promise { - const providers = this.providers.list(); - const models = this.models.list(); - const globalDefaultModel = this.models.getDefaultModel(); - const out: ProviderCatalogItem[] = []; - for (const [providerId, provider] of Object.entries(providers)) { - out.push(await this.toCatalogProvider(providerId, provider, models, globalDefaultModel)); - } - return out; - } - - async getProvider(providerId: string): Promise { - const provider = this.providers.get(providerId); - if (provider === undefined) { - throw new Error2( - ModelCatalogErrors.codes.PROVIDER_NOT_FOUND, - `provider ${providerId} does not exist`, - ); - } - const models = this.models.list(); - const globalDefaultModel = this.models.getDefaultModel(); - return this.toCatalogProvider(providerId, provider, models, globalDefaultModel); - } - - async setDefaultModel(modelId: string): Promise { - const record = this.models.get(modelId); - if (record === undefined) { - throw new Error2( - ModelCatalogErrors.codes.MODEL_NOT_FOUND, - `model ${modelId} does not exist`, - ); - } - const model = this.get(modelId); - await this.models.setDefaultModel(modelId); - return { - default_model: modelId, - model: toProtocolModel(model, record, this.providerTypeOf(record)), - }; - } - - private async toCatalogProvider( - providerId: string, - provider: ProviderConfig, - models: Readonly>, - globalDefaultModel: string | undefined, - ): Promise { - const credential = await this.resolveCredential(providerId, provider); - return toProtocolProvider(providerId, provider, models, globalDefaultModel, credential); - } - - private async resolveCredential( - providerId: string, - provider: ProviderConfig, - ): Promise { - return { - hasApiKey: hasConfiguredApiKey(provider), - hasOAuthToken: await this.hasCachedToken(providerId, provider), - }; - } - - private async hasCachedToken(providerId: string, provider: ProviderConfig): Promise { - if (provider.oauth === undefined) return false; - return this.oauth.hasCachedAccessToken(providerId, provider.oauth); - } - - private providerTypeOf(record: ModelRecord): string | undefined { - const providerId = - record.providerId ?? record.provider ?? this.providers.getDefaultProvider(); - return this.providers.get(providerId ?? '')?.type ?? record.protocol; - } - - private buildModel(id: string, trace: ResolutionTraceCollector): Model { - const configuredModel = this.models.get(id); - if (configuredModel === undefined) { - throw new Error2( - CONFIG_INVALID_ERROR_CODE, - `Model "${id}" is not configured in config.toml.`, - { details: { model: id } }, - ); - } - trace.capture(TRACE.configuredModel, configuredModel); - trace.record('model.record', { kind: 'config', detail: '[models.*] section' }); - - const routingModel = effectiveModelConfig(configuredModel); - const { providerConfig, providerName, resolvedBaseUrl: rawBaseUrl } = - this.resolveProviderContext(id, routingModel, trace); - trace.capture(TRACE.providerConfig, providerConfig); - trace.capture(TRACE.providerName, providerName); - trace.capture(TRACE.rawBaseUrl, rawBaseUrl); - - const protocol = this.resolveProtocol(id, routingModel, providerConfig, trace); - const model = effectiveModelConfig( - configuredModel, - providerConfig?.type ?? configuredModel.protocol, - ); - trace.capture(TRACE.effectiveModel, model); - const wireName = model.name ?? model.model; - const profileAttribution = profileForAttribution(configuredModel, providerConfig, wireName); - attributeEffectiveFields( - trace, - configuredModel, - model, - profileAttribution.profile, - profileAttribution.inferred, - ); - - const auth = resolveModelAuthMaterial( - { - modelId: id, - model, - provider: providerConfig, - providerName, - }, - trace, - ); - trace.capture(TRACE.authMaterial, auth); - const authProvider = this.buildAuthProvider(providerName, auth); - - const providerType = providerConfig?.type ?? protocol; - const resolvedBaseUrl = - protocol === 'anthropic' && rawBaseUrl !== undefined - ? stripTrailingV1(rawBaseUrl) - : rawBaseUrl; - if (wireName === undefined) { - throw new Error2( - CONFIG_INVALID_ERROR_CODE, - `Model "${id}" must define a wire-facing name in config.toml.`, - ); - } - if (model.maxContextSize === undefined) { - throw new Error2( - CONFIG_INVALID_ERROR_CODE, - `Model "${id}" must define a positive max_context_size in config.toml.`, - ); - } - - const explainedCapability = this.protocolRegistry.explainCapability( - protocol, - wireName, - providerType, - ); - trace.capture(TRACE.detectedCapability, explainedCapability.capability); - trace.capture(TRACE.capabilitySource, explainedCapability.source); - const capabilities = resolveModelCapabilities( - model.capabilities, - explainedCapability.capability, - model.maxContextSize, - model.maxInputSize, - ); - const providerOptions = buildProtocolProviderOptions( - model, - protocol, - providerConfig, - resolvedBaseUrl, - ); - if (providerOptions !== undefined) { - attributeProviderOptions(trace, providerOptions, providerConfig?.env); - } - const declared = new Set((model.capabilities ?? []).map((c) => c.trim().toLowerCase())); - - trace.capture(TRACE.hostHeaders, this.hostRequestHeaders.headers); - trace.capture(TRACE.thirdPartyHeaders, this.hostRequestHeaders.thirdPartyHeaders); - trace.capture(TRACE.identitySlug, this.hostRequestHeaders.identitySlug); - return { - id, - name: wireName, - aliases: model.aliases ?? [], - protocol, - baseUrl: resolvedBaseUrl, - headers: resolveOutboundHeaders( - providerConfig?.type, - providerConfig?.customHeaders, - this.hostRequestHeaders, - ), - capabilities, - maxContextSize: model.maxContextSize, - maxInputSize: model.maxInputSize, - maxOutputSize: model.maxOutputSize, - displayName: model.displayName, - reasoningKey: model.reasoningKey, - supportEfforts: model.supportEfforts, - defaultEffort: model.defaultEffort, - alwaysThinking: declared.has('always_thinking'), - providerType, - providerName, - authProvider, - providerOptions, - }; - } - - private resolveProviderContext( - id: string, - model: ModelRecord, - trace: ResolutionTraceCollector, - ): { - readonly providerConfig: ProviderConfig | undefined; - readonly providerName: string; - readonly resolvedBaseUrl: string | undefined; - } { - const providerId = - model.providerId ?? model.provider ?? this.providers.getDefaultProvider(); - if (providerId !== undefined) { - trace.record('provider', { - kind: 'config', - detail: - model.providerId !== undefined - ? `model.providerId '${providerId}'` - : model.provider !== undefined - ? `model.provider '${providerId}'` - : `[defaultProvider] '${providerId}'`, - }); - trace.capture(TRACE.providerSynthesized, false); - const providerConfig = this.providers.get(providerId); - if (providerConfig === undefined) { - throw new Error2( - CONFIG_INVALID_ERROR_CODE, - `Provider "${providerId}" referenced by model "${id}" is not configured.`, - ); - } - const fromModel = nonEmpty(model.baseUrl); - const fromProvider = nonEmpty(providerConfig.baseUrl); - let baseUrl: string | undefined; - if (fromModel !== undefined) { - baseUrl = fromModel; - trace.record('resolved.baseUrl', { kind: 'config', detail: 'model.baseUrl' }); - } else if (fromProvider !== undefined) { - baseUrl = fromProvider; - trace.record('resolved.baseUrl', { - kind: 'config', - detail: `provider '${providerId}' baseUrl`, - }); - } else { - const endpointType = providerConfig.type ?? model.protocol; - const endpoint = - endpointType === undefined - ? {} - : explainProviderEndpoint(endpointType, providerConfig.env ?? {}); - baseUrl = nonEmpty(endpoint.baseUrl); - if (endpoint.baseUrlEnvName !== undefined) { - trace.record('resolved.baseUrl', { - kind: 'env', - detail: `${endpoint.baseUrlEnvName} (provider '${providerId}' env bag)`, - }); - } else if (endpoint.baseUrlIsDefault === true) { - trace.record('resolved.baseUrl', { - kind: 'builtin', - detail: `provider definition '${endpointType}' defaultBaseUrl`, - }); - } - } - return { providerConfig, providerName: providerId, resolvedBaseUrl: baseUrl }; - } - - const modelBaseUrl = nonEmpty(model.baseUrl); - if (modelBaseUrl === undefined) { - throw new Error2( - CONFIG_INVALID_ERROR_CODE, - `Model "${id}" must set either providerId or baseUrl in config.toml.`, - ); - } - trace.record('provider', { - kind: 'synthesized', - detail: 'flat model — provider synthesized from the baseUrl host', - }); - trace.capture(TRACE.providerSynthesized, true); - trace.record('resolved.baseUrl', { kind: 'config', detail: 'model.baseUrl (flat)' }); - const originName = deriveProviderId(modelBaseUrl); - return { - providerConfig: undefined, - providerName: originName, - resolvedBaseUrl: modelBaseUrl, - }; - } - - private resolveProtocol( - id: string, - model: ModelRecord, - provider: ProviderConfig | undefined, - trace: ResolutionTraceCollector, - ): Protocol { - const resolution = resolveModelProtocol(model, provider); - if (resolution === undefined) { - throw new Error2( - CONFIG_INVALID_ERROR_CODE, - `Model "${id}" must declare a wire protocol (config: models..protocol).`, - ); - } - trace.record('resolved.protocol', resolution.source); - return resolution.protocol; - } - - private buildAuthProvider(providerName: string, auth: ResolvedModelAuthMaterial): AuthProvider { - if (auth.apiKey !== undefined) { - return new StaticAuthProvider(auth.apiKey); - } - if (auth.oauth !== undefined) { - const oauthRef = auth.oauth; - const providerKey = auth.oauthProviderKey ?? providerName; - const tokens = this.oauth; - return { - canRefresh: true, - async getAuth(options): Promise { - const apiKey = await tokens.getAccessToken(providerKey, oauthRef, { - force: options?.force === true, - }); - return { apiKey }; - }, - }; - } - return new StaticAuthProvider(undefined); - } -} - -export function resolveOutboundHeaders( - providerType: string | undefined, - customHeaders: Readonly> | undefined, - host: Pick, -): Readonly> { - const forwardsAll = - providerType !== undefined && - getProviderDefinition(providerType)?.hostHeaders === 'full'; - const hostLayer = forwardsAll ? host.headers : host.thirdPartyHeaders; - return { ...parsePythinkerCodeCustomHeaders(), ...hostLayer, ...customHeaders }; -} - -function resolveModelCapabilities( - declaredCapabilities: readonly string[] | undefined, - detected: ModelCapability, - maxContextSize: number, - maxInputSize: number | undefined, -): ModelCapability { - const declared = new Set((declaredCapabilities ?? []).map((c) => c.trim().toLowerCase())); - return { - image_in: declared.has('image_in') || detected.image_in, - video_in: declared.has('video_in') || detected.video_in, - audio_in: declared.has('audio_in') || detected.audio_in, - thinking: declared.has('thinking') || declared.has('always_thinking') || detected.thinking, - tool_use: declared.has('tool_use') || detected.tool_use, - max_context_tokens: maxContextSize, - max_input_tokens: maxInputSize, - dynamically_loaded_tools: - declared.has('dynamically_loaded_tools') || - detected.dynamically_loaded_tools === true, - }; -} - -function stripTrailingV1(baseUrl: string): string { - return baseUrl.replace(/\/v1\/?$/, ''); -} - -function buildProtocolProviderOptions( - model: ModelRecord, - protocol: Protocol, - provider: ProviderConfig | undefined, - baseUrl: string | undefined, -): ProtocolProviderOptions | undefined { - const options: MutableProtocolProviderOptions = {}; - - switch (protocol) { - case 'anthropic': - if (model.maxOutputSize !== undefined) options.defaultMaxTokens = model.maxOutputSize; - if (model.supportEfforts !== undefined) options.supportEfforts = model.supportEfforts; - if (model.adaptiveThinking !== undefined) options.adaptiveThinking = model.adaptiveThinking; - if (model.betaApi !== undefined) options.betaApi = model.betaApi; - break; - case 'openai': { - const reasoningKey = nonEmpty(model.reasoningKey); - if (reasoningKey !== undefined) options.reasoningKey = reasoningKey; - if (model.offEffort !== undefined) options.offEffort = model.offEffort; - break; - } - case 'google-genai': { - const project = vertexAIProject(provider); - const location = vertexAILocation(provider, baseUrl); - if (project !== undefined && location !== undefined) { - options.vertexai = true; - options.project = project; - options.location = location; - } - break; - } - case 'openai_responses': - if (model.offEffort !== undefined) options.offEffort = model.offEffort; - break; - default: { - const exhaustive: never = protocol; - void exhaustive; - } - } - - return Object.values(options).some((value) => value !== undefined) - ? options - : undefined; -} - -function profileForAttribution( - configuredModel: ModelRecord, - providerConfig: ProviderConfig | undefined, - wireName: string | undefined, -): { readonly profile: typeof LATEST_OPUS_PROFILE | undefined; readonly inferred: boolean } { - if (wireName === undefined) return { profile: undefined, inferred: false }; - const profileArg = providerConfig?.type ?? configuredModel.protocol; - const gateProtocol = configuredModel.protocol ?? profileArg; - const known = matchKnownAnthropicModelProfile(wireName); - const infer = - profileArg !== undefined && - !drivesThinkingThroughTraits(profileArg) && - gateProtocol === 'anthropic'; - if (infer) { - const fallback = known ?? matchUnknownClaudeProfile(wireName); - return { profile: fallback, inferred: known === undefined && fallback !== undefined }; - } - return { profile: known, inferred: false }; -} - -function vertexAIProject(provider: ProviderConfig | undefined): string | undefined { - return envValue(provider?.env, 'GOOGLE_CLOUD_PROJECT'); -} - -function vertexAILocation( - provider: ProviderConfig | undefined, - baseUrl: string | undefined, -): string | undefined { - return envValue(provider?.env, 'GOOGLE_CLOUD_LOCATION') ?? locationFromVertexAIBaseUrl(baseUrl); -} - -function envValue(env: Record | undefined, key: string): string | undefined { - return nonEmpty(env?.[key]); -} - -function locationFromVertexAIBaseUrl(baseUrl: string | undefined): string | undefined { - const url = nonEmpty(baseUrl); - if (url === undefined) return undefined; - try { - const host = new URL(url).hostname; - const labels = host.split('.'); - if (labels.length !== 3 || labels[1] !== 'googleapis' || labels[2] !== 'com') { - return undefined; - } - const service = labels[0]!; - const suffix = '-aiplatform'; - return service.endsWith(suffix) ? nonEmpty(service.slice(0, -suffix.length)) : undefined; - } catch { - return undefined; - } -} - -function hasConfiguredApiKey(provider: ProviderConfig): boolean { - if (nonEmpty(provider.apiKey) !== undefined) return true; - if (provider.type === undefined) return false; - return resolveProviderEndpoint(provider.type, provider.env ?? {}).apiKey !== undefined; -} - -registerScopedService( - LifecycleScope.App, - IModelCatalog, - ModelCatalog, - ScopeActivation.OnScopeCreated, - 'modelCatalog', -); diff --git a/packages/agent-core-v2/src/kosong/model/completionBudget.ts b/packages/agent-core-v2/src/kosong/model/completionBudget.ts deleted file mode 100644 index 49bfd515c..000000000 --- a/packages/agent-core-v2/src/kosong/model/completionBudget.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { ModelCapability } from '#/kosong/contract/capability'; - -import type { CompletionBudgetConfig, CompletionBudgetParams } from './model.types'; - -const MIN_FLOOR = 1; -const DEFAULT_UNKNOWN_CONTEXT_FALLBACK = 32000; - -export function resolveCompletionBudget(args: { - readonly maxOutputSize?: number; - readonly reservedContextSize?: number; - readonly maxCompletionTokensCap?: number; -}): CompletionBudgetConfig | undefined { - if (args.maxCompletionTokensCap !== undefined) { - if (args.maxCompletionTokensCap <= 0) return undefined; - return { hardCap: args.maxCompletionTokensCap }; - } - if (args.maxOutputSize !== undefined && args.maxOutputSize > 0) { - return { hardCap: args.maxOutputSize }; - } - if (args.reservedContextSize !== undefined && args.reservedContextSize > 0) { - return { fallback: args.reservedContextSize }; - } - return { fallback: DEFAULT_UNKNOWN_CONTEXT_FALLBACK }; -} - -export function computeCompletionBudgetCap(args: { - readonly budget: CompletionBudgetConfig; - readonly capability: ModelCapability | undefined; -}): number { - const maxCtx = args.capability?.max_context_tokens ?? 0; - const cap = - args.budget.hardCap ?? - (maxCtx > 0 ? maxCtx : args.budget.fallback ?? DEFAULT_UNKNOWN_CONTEXT_FALLBACK); - return Math.max(MIN_FLOOR, cap); -} - -export function completionBudgetParams(args: { - readonly budget: CompletionBudgetConfig | undefined; - readonly capability: ModelCapability | undefined; - readonly usedContextTokens?: number; -}): CompletionBudgetParams | undefined { - if (args.budget === undefined) return undefined; - return { - maxCompletionTokens: computeCompletionBudgetCap({ - budget: args.budget, - capability: args.capability, - }), - usedContextTokens: args.usedContextTokens, - maxContextTokens: args.capability?.max_context_tokens, - }; -} diff --git a/packages/agent-core-v2/src/kosong/model/defaultModelPolicy.ts b/packages/agent-core-v2/src/kosong/model/defaultModelPolicy.ts deleted file mode 100644 index 44da640e9..000000000 --- a/packages/agent-core-v2/src/kosong/model/defaultModelPolicy.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { ModelRecord, ModelsSection } from './model'; - -const TOOL_USE_CAPABILITY = 'tool_use'; - -export function isEligibleDefaultModel(record: ModelRecord): boolean { - const context = effectiveContextSize(record); - if (context !== undefined && context <= 0) return false; - const capabilities = effectiveCapabilities(record); - if (capabilities === undefined || capabilities.length === 0) return true; - return capabilities.some((entry) => entry.trim().toLowerCase() === TOOL_USE_CAPABILITY); -} - -export function rankDefaultModelCandidates(models: ModelsSection): string[] { - return Object.entries(models) - .filter(([, record]) => isEligibleDefaultModel(record)) - .map(([id, record]) => ({ - id, - declaresToolUse: declaresToolUse(record), - context: effectiveContextSize(record) ?? 0, - })) - .toSorted((a, b) => { - if (a.declaresToolUse !== b.declaresToolUse) return a.declaresToolUse ? -1 : 1; - if (a.context !== b.context) return b.context - a.context; - const aParts = splitModelId(a.id); - const bParts = splitModelId(b.id); - if (aParts.providerId !== bParts.providerId) { - return aParts.providerId.localeCompare(bParts.providerId); - } - const byModelPart = bParts.modelPart.localeCompare(aParts.modelPart, 'en', { - numeric: true, - }); - if (byModelPart !== 0) return byModelPart; - return a.id.localeCompare(b.id); - }) - .map((candidate) => candidate.id); -} - -function splitModelId(id: string): { readonly providerId: string; readonly modelPart: string } { - const slash = id.indexOf('/'); - if (slash === -1) return { providerId: '', modelPart: id }; - return { providerId: id.slice(0, slash), modelPart: id.slice(slash + 1) }; -} - -export function resolveDefaultModel( - models: ModelsSection, - current: string | undefined, - isReady: (id: string) => boolean = () => true, - lastUsed?: string, -): string | undefined { - if (current !== undefined && isReady(current)) return current; - if (lastUsed !== undefined && isReady(lastUsed)) return lastUsed; - return rankDefaultModelCandidates(models).find((candidate) => isReady(candidate)); -} - -function effectiveCapabilities(record: ModelRecord): readonly string[] | undefined { - return record.overrides?.capabilities ?? record.capabilities; -} - -function effectiveContextSize(record: ModelRecord): number | undefined { - return record.overrides?.maxContextSize ?? record.maxContextSize; -} - -function declaresToolUse(record: ModelRecord): boolean { - const capabilities = effectiveCapabilities(record); - if (capabilities === undefined) return false; - return capabilities.some((entry) => entry.trim().toLowerCase() === TOOL_USE_CAPABILITY); -} diff --git a/packages/agent-core-v2/src/kosong/model/errors.ts b/packages/agent-core-v2/src/kosong/model/errors.ts deleted file mode 100644 index 287d290a1..000000000 --- a/packages/agent-core-v2/src/kosong/model/errors.ts +++ /dev/null @@ -1 +0,0 @@ -export { ModelCatalogErrors } from '#/llm-adapter/model/errors'; diff --git a/packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts b/packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts deleted file mode 100644 index 6e040271d..000000000 --- a/packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createDecorator } from '#/_base/di/instantiation'; - -export interface IHostRequestHeaders { - readonly headers: Readonly>; - readonly thirdPartyHeaders: Readonly>; - readonly identitySlug?: string; -} - -export const IHostRequestHeaders = createDecorator('hostRequestHeaders'); diff --git a/packages/agent-core-v2/src/kosong/model/inspection.ts b/packages/agent-core-v2/src/kosong/model/inspection.ts deleted file mode 100644 index 0a9d8192d..000000000 --- a/packages/agent-core-v2/src/kosong/model/inspection.ts +++ /dev/null @@ -1,497 +0,0 @@ -import { parsePythinkerCodeCustomHeaders } from '@pymodel/pythinker-code-oauth'; - -import { BugIndicatingError } from '#/_base/errors/errors'; - -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { InspectionSource, ResolutionTrace } from '#/kosong/contract/inspection'; -import type { Protocol, ProtocolProviderOptions } from '#/kosong/protocol/protocol'; - -import type { AnthropicModelProfile } from '../provider/bases/anthropic/anthropic-profile'; -import type { ProviderConfig } from '../provider/provider'; -import { getProviderDefinition } from '../provider/providerDefinition'; - -import type { ModelRecord } from './model'; -import type { ResolvedModelAuthMaterial } from './model.types'; - -export interface InspectedAuth { - readonly kind: 'apiKey' | 'oauth' | 'none'; - readonly apiKey?: string; - readonly oauthProviderKey?: string; -} - -export interface InspectedResolvedModel { - readonly protocol: Protocol; - readonly providerType?: string; - readonly providerName: string; - readonly baseUrl?: string; - readonly wireName: string; - readonly aliases: readonly string[]; - readonly auth: InspectedAuth; - readonly capabilities: ModelCapability; - readonly maxContextSize: number; - readonly maxInputSize?: number; - readonly maxOutputSize?: number; - readonly displayName?: string; - readonly reasoningKey?: string; - readonly supportEfforts?: readonly string[]; - readonly defaultEffort?: string; - readonly alwaysThinking: boolean; - readonly headers: Readonly>; - readonly providerOptions?: ProtocolProviderOptions; -} - -export interface ModelInspection { - readonly id: string; - readonly model: { - readonly id: string; - readonly record: ModelRecord; - readonly effective: ModelRecord; - }; - readonly provider: { - readonly id: string; - readonly synthesized: boolean; - readonly config?: ProviderConfig; - readonly definition?: { - readonly registered: boolean; - readonly baseProtocol?: Protocol; - readonly modelSource?: string; - readonly hostHeaders?: string; - readonly endpoint?: unknown; - }; - }; - readonly resolved: InspectedResolvedModel; - readonly sources: Readonly>; -} - -export const TRACE = { - configuredModel: 'configuredModel', - effectiveModel: 'effectiveModel', - providerConfig: 'providerConfig', - providerName: 'providerName', - providerSynthesized: 'providerSynthesized', - rawBaseUrl: 'rawBaseUrl', - authMaterial: 'authMaterial', - detectedCapability: 'detectedCapability', - capabilitySource: 'capabilitySource', - hostHeaders: 'hostHeaders', - thirdPartyHeaders: 'thirdPartyHeaders', - identitySlug: 'identitySlug', -} as const; - -export class ResolutionTraceCollector implements ResolutionTrace { - private readonly sourceMap = new Map(); - private readonly captureMap = new Map(); - - record(path: string, source: InspectionSource): void { - this.sourceMap.set(path, source); - } - - capture(key: string, value: unknown): void { - this.captureMap.set(key, value); - } - - captured(key: string): T | undefined { - return this.captureMap.get(key) as T | undefined; - } - - get sources(): ReadonlyMap { - return this.sourceMap; - } -} - -const SECRET_KEY_RE = /api[-_]?key|token|secret|password|authorization/i; - -export function maskSecret(value: string): string { - if (value.length <= 4) return '••••'; - return `••••${value.slice(-4)}`; -} - -export function redactSecrets(value: T): T { - if (Array.isArray(value)) return value.map((item) => redactSecrets(item)) as T; - if (value !== null && typeof value === 'object') { - const out: Record = {}; - for (const [key, item] of Object.entries(value)) { - out[key] = typeof item === 'string' && SECRET_KEY_RE.test(key) ? maskSecret(item) : redactSecrets(item); - } - return out as T; - } - return value; -} - -export function attributeEffectiveFields( - trace: ResolutionTraceCollector, - configured: ModelRecord, - effective: ModelRecord, - profile: AnthropicModelProfile | undefined, - profileInferred: boolean, -): void { - const { overrides, ...base } = configured; - const overridden = new Set(Object.keys(overrides ?? {})); - const profileDetail = - profile === undefined - ? undefined - : `anthropic profile (${profile.mode}, efforts: ${profile.efforts.join('/')}${profileInferred ? ', inferred fallback' : ''})`; - const keys = new Set([...Object.keys(base), ...Object.keys(effective)]); - for (const key of keys) { - if (key === 'overrides') continue; - const path = `model.effective.${key}`; - const before = (base as Record)[key]; - const after = (effective as Record)[key]; - if (before === undefined && after === undefined) continue; - if (key === 'maxInputSize') { - const rawValue = (overridden.has(key) ? overrides?.[key] : before) as number | undefined; - if ( - rawValue !== undefined && - effective.maxContextSize !== undefined && - rawValue > effective.maxContextSize - ) { - trace.record(path, { - kind: 'synthesized', - detail: 'clamped to the effective max_context_size', - }); - continue; - } - } - if (overridden.has(key)) { - trace.record(path, { kind: 'override', detail: 'models.*.overrides' }); - continue; - } - if (after === undefined) { - trace.record(path, { - kind: 'synthesized', - detail: 'removed by the effective pass (defaultEffort not in override supportEfforts)', - }); - continue; - } - const profileTouched = - (key === 'capabilities' || key === 'supportEfforts' || key === 'defaultEffort') && - profileDetail !== undefined && - JSON.stringify(before) !== JSON.stringify(after); - if (profileTouched) { - trace.record(path, { kind: 'builtin', detail: profileDetail }); - continue; - } - trace.record(path, { kind: 'config', detail: '[models.*] section' }); - } -} - -const PROVIDER_OPTION_FIELD: Readonly> = { - defaultMaxTokens: 'maxOutputSize', - supportEfforts: 'supportEfforts', - adaptiveThinking: 'adaptiveThinking', - betaApi: 'betaApi', - reasoningKey: 'reasoningKey', -}; - -export function attributeProviderOptions( - trace: ResolutionTraceCollector, - options: ProtocolProviderOptions, - providerEnv: Readonly> | undefined, -): void { - for (const key of Object.keys(options)) { - const path = `resolved.providerOptions.${key}`; - if (key === 'vertexai') { - trace.record(path, { kind: 'env', detail: 'provider env bag supplies both vertex coordinates' }); - continue; - } - if (key === 'project') { - trace.record(path, { kind: 'env', detail: 'GOOGLE_CLOUD_PROJECT (provider env bag)' }); - continue; - } - if (key === 'location') { - trace.record( - path, - providerEnv?.['GOOGLE_CLOUD_LOCATION'] !== undefined - ? { kind: 'env', detail: 'GOOGLE_CLOUD_LOCATION (provider env bag)' } - : { kind: 'synthesized', detail: 'parsed from the baseUrl host' }, - ); - continue; - } - const field = PROVIDER_OPTION_FIELD[key]; - const source = field === undefined ? undefined : trace.sources.get(`model.effective.${field}`); - trace.record(path, source ?? { kind: 'config', detail: '[models.*] section' }); - } -} - -interface ResolvedModelLike { - readonly protocol: Protocol; - readonly providerType?: string; - readonly providerName: string; - readonly baseUrl?: string; - readonly name: string; - readonly aliases: readonly string[]; - readonly capabilities: ModelCapability; - readonly maxContextSize: number; - readonly maxInputSize?: number; - readonly maxOutputSize?: number; - readonly displayName?: string; - readonly reasoningKey?: string; - readonly supportEfforts?: readonly string[]; - readonly defaultEffort?: string; - readonly alwaysThinking: boolean; - readonly headers: Readonly>; - readonly providerOptions?: ProtocolProviderOptions; -} - -const CAPABILITY_KEYS = [ - 'image_in', - 'video_in', - 'audio_in', - 'thinking', - 'tool_use', - 'dynamically_loaded_tools', -] as const; - -export function assembleModelInspection(args: { - readonly id: string; - readonly model: ResolvedModelLike; - readonly trace: ResolutionTraceCollector; -}): ModelInspection { - const { id, model, trace } = args; - const configured = required(trace, TRACE.configuredModel, 'configured model'); - const effective = required(trace, TRACE.effectiveModel, 'effective model'); - const providerConfig = trace.captured(TRACE.providerConfig); - const providerName = trace.captured(TRACE.providerName) ?? model.providerName; - const providerSynthesized = trace.captured(TRACE.providerSynthesized) === true; - const rawBaseUrl = trace.captured(TRACE.rawBaseUrl); - const authMaterial = trace.captured(TRACE.authMaterial) ?? {}; - - const sources = new Map([ - ...trace.sources, - [ - 'model.effective', - { - kind: 'synthesized', - detail: 'overrides merged into the raw record, then the Anthropic profile pass fills gaps', - } satisfies InspectionSource, - ], - [ - 'resolved', - { - kind: 'synthesized', - detail: 'the assembled runtime view (Model) of this same resolution pass', - } satisfies InspectionSource, - ], - ]); - - for (const field of [ - 'maxContextSize', - 'maxInputSize', - 'maxOutputSize', - 'displayName', - 'reasoningKey', - 'supportEfforts', - 'defaultEffort', - 'aliases', - ] as const) { - const source = sources.get(`model.effective.${field}`); - if (source !== undefined) sources.set(`resolved.${field}`, source); - } - const wireNameField = effective.name !== undefined ? 'name' : 'model'; - sources.set( - 'resolved.wireName', - sources.get(`model.effective.${wireNameField}`) ?? { kind: 'config', detail: '[models.*] section' }, - ); - sources.set('resolved.alwaysThinking', { - kind: 'synthesized', - detail: "derived from the declared capabilities ('always_thinking' present)", - }); - sources.set( - 'resolved.providerType', - providerConfig !== undefined - ? { kind: 'config', detail: `provider '${providerName}' type` } - : { kind: 'synthesized', detail: 'no provider — falls back to the resolved protocol' }, - ); - sources.set( - 'resolved.providerName', - sources.get('provider') ?? { kind: 'config', detail: `provider '${providerName}'` }, - ); - - sources.set('model', { kind: 'config', detail: 'the [models.*] section entry' }); - sources.set('model.id', { kind: 'config', detail: 'the [models.*] section key' }); - sources.set('resolved.headers', { - kind: 'synthesized', - detail: 'env < host < provider customHeaders merge (later wins)', - }); - - const baseUrlSource = sources.get('resolved.baseUrl'); - if ( - baseUrlSource !== undefined && - model.protocol === 'anthropic' && - rawBaseUrl !== undefined && - rawBaseUrl !== model.baseUrl - ) { - sources.set('resolved.baseUrl', { - kind: 'synthesized', - detail: `${baseUrlSource.detail ?? baseUrlSource.kind} · trailing /v1 stripped`, - }); - } - - attributeCapabilities(sources, configured, effective, trace); - attributeHeaders(sources, model, providerConfig, trace); - - const providerType = providerConfig?.type; - const definition = providerType === undefined ? undefined : getProviderDefinition(providerType); - if (providerConfig !== undefined) { - sources.set('provider.config', { kind: 'config', detail: '[providers.*] section' }); - sources.set('provider.definition', { - kind: 'builtin', - detail: - definition === undefined - ? `vendor '${providerType}' is not registered in the provider-definition registry` - : `provider definition '${providerType}'`, - }); - } - - const auth: InspectedAuth = - authMaterial.apiKey !== undefined - ? { kind: 'apiKey', apiKey: maskSecret(authMaterial.apiKey) } - : authMaterial.oauth !== undefined - ? { kind: 'oauth', oauthProviderKey: authMaterial.oauthProviderKey } - : { kind: 'none' }; - - return { - id, - model: { - id, - record: redactSecrets(configured), - effective: redactSecrets(effective), - }, - provider: { - id: providerName, - synthesized: providerSynthesized, - config: providerConfig === undefined ? undefined : redactSecrets(providerConfig), - definition: - providerConfig === undefined - ? undefined - : { - registered: definition !== undefined, - ...(definition === undefined - ? undefined - : { - baseProtocol: definition.baseProtocol, - modelSource: definition.modelSource, - hostHeaders: definition.hostHeaders, - endpoint: definition.endpoint, - }), - }, - }, - resolved: { - protocol: model.protocol, - providerType: model.providerType, - providerName: model.providerName, - baseUrl: model.baseUrl, - wireName: model.name, - aliases: model.aliases, - auth, - capabilities: model.capabilities, - maxContextSize: model.maxContextSize, - maxInputSize: model.maxInputSize, - maxOutputSize: model.maxOutputSize, - displayName: model.displayName, - reasoningKey: model.reasoningKey, - supportEfforts: model.supportEfforts, - defaultEffort: model.defaultEffort, - alwaysThinking: model.alwaysThinking, - headers: model.headers, - providerOptions: model.providerOptions, - }, - sources: Object.fromEntries(sources), - }; -} - -function attributeCapabilities( - sources: Map, - configured: ModelRecord, - effective: ModelRecord, - trace: ResolutionTraceCollector, -): void { - const raw = new Set((configured.capabilities ?? []).map((c) => c.trim().toLowerCase())); - const added = new Set((effective.capabilities ?? []).map((c) => c.trim().toLowerCase())); - const detected = trace.captured(TRACE.detectedCapability); - const detectedSource = trace.captured(TRACE.capabilitySource) ?? { - kind: 'none' as const, - }; - const profileSource = sources.get('model.effective.capabilities'); - for (const key of CAPABILITY_KEYS) { - const path = `resolved.capabilities.${key}`; - if (raw.has(key) || (key === 'thinking' && raw.has('always_thinking'))) { - sources.set(path, { kind: 'config', detail: 'declared in model capabilities' }); - continue; - } - if (added.has(key) || (key === 'thinking' && added.has('always_thinking'))) { - sources.set( - path, - profileSource ?? { kind: 'builtin', detail: 'added by the Anthropic profile pass' }, - ); - continue; - } - if (detected?.[key] === true) { - sources.set(path, detectedSource); - continue; - } - sources.set(path, { kind: 'none', detail: 'neither declared nor detected' }); - } - sources.set('resolved.capabilities.max_context_tokens', { - kind: 'synthesized', - detail: 'forced to the resolved maxContextSize', - }); - const maxInputSource = sources.get('model.effective.maxInputSize'); - sources.set( - 'resolved.capabilities.max_input_tokens', - maxInputSource ?? { - kind: 'none', - detail: 'no declared input limit — the total window applies', - }, - ); -} - -function hostHeaderDetail( - forwardsAll: boolean, - key: string, - identitySlug: string | undefined, -): string { - if (forwardsAll) return "host request headers (hostHeaders: 'full')"; - return identitySlug !== undefined && key === 'User-Agent' - ? `host User-Agent, product token from [identity] (${identitySlug})` - : 'host User-Agent'; -} - -function attributeHeaders( - sources: Map, - model: ResolvedModelLike, - providerConfig: ProviderConfig | undefined, - trace: ResolutionTraceCollector, -): void { - const envLayer = parsePythinkerCodeCustomHeaders(); - const rawHost = trace.captured>>(TRACE.hostHeaders) ?? {}; - const identitySlug = trace.captured(TRACE.identitySlug); - const forwardsAll = - providerConfig?.type !== undefined && - getProviderDefinition(providerConfig.type)?.hostHeaders === 'full'; - const hostLayer: Readonly> = forwardsAll - ? rawHost - : trace.captured>>(TRACE.thirdPartyHeaders) ?? {}; - const customLayer = providerConfig?.customHeaders ?? {}; - for (const key of Object.keys(model.headers)) { - const path = `resolved.headers.${key}`; - if (key in customLayer) { - sources.set(path, { kind: 'config', detail: "provider's customHeaders" }); - } else if (key in hostLayer) { - sources.set(path, { - kind: 'builtin', - detail: hostHeaderDetail(forwardsAll, key, identitySlug), - }); - } else if (key in envLayer) { - sources.set(path, { kind: 'env', detail: 'PYTHINKER_CODE_CUSTOM_HEADERS' }); - } - } -} - -function required(trace: ResolutionTraceCollector, key: string, what: string): T { - const value = trace.captured(key); - if (value === undefined) { - throw new BugIndicatingError(`resolution trace is missing the ${what} capture ('${key}')`); - } - return value; -} diff --git a/packages/agent-core-v2/src/kosong/model/model.ts b/packages/agent-core-v2/src/kosong/model/model.ts deleted file mode 100644 index ce8397910..000000000 --- a/packages/agent-core-v2/src/kosong/model/model.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { Event, IWaitUntil } from '#/_base/event'; -import type { Protocol } from '#/kosong/protocol/protocol'; - -import type { OAuthRef } from '../provider/provider'; - -export interface ModelOverride { - maxContextSize?: number; - maxInputSize?: number; - maxOutputSize?: number; - capabilities?: string[]; - displayName?: string; - reasoningKey?: string; - adaptiveThinking?: boolean; - supportEfforts?: string[]; - defaultEffort?: string; - offEffort?: string; -} - -export interface ModelRecord { - providerId?: string; - - baseUrl?: string; - apiKey?: string; - oauth?: OAuthRef; - - protocol?: Protocol; - - name?: string; - aliases?: string[]; - - provider?: string; - model?: string; - maxContextSize?: number; - maxInputSize?: number; - maxOutputSize?: number; - capabilities?: string[]; - displayName?: string; - reasoningKey?: string; - adaptiveThinking?: boolean; - betaApi?: boolean; - supportEfforts?: string[]; - defaultEffort?: string; - offEffort?: string; - - overrides?: ModelOverride; - - [key: string]: unknown; -} - -export type ModelsSection = Record; - -export function modelRecordProviderId(record: ModelRecord): string | undefined { - return record.providerId ?? record.provider; -} - -export interface ModelsChangedEvent { - readonly added: readonly string[]; - readonly removed: readonly string[]; - readonly changed: readonly string[]; -} - -export interface DefaultModelChangedEvent { - readonly id: string | undefined; -} - -export interface IModelService { - readonly _serviceBrand: undefined; - - readonly ready: Promise; - readonly settled: Promise; - readonly onDidChangeModels: Event; - readonly onDidChangeDefaultModel: Event; - readonly onDidChangeLastUsedModel: Event; - get(id: string): ModelRecord | undefined; - list(): Readonly>; - getDefaultModel(): string | undefined; - getLastUsedModel(): string | undefined; - set(id: string, model: ModelRecord): Promise; - delete(id: string): Promise; - loadAll(models: ModelsSection, defaultModel: string | undefined, lastUsedModel: string | undefined): void; - replaceAll(models: ModelsSection): Promise; - setDefaultModel(id: string | undefined): Promise; - setLastUsedModel(id: string | undefined): Promise; -} - -export const IModelService: ServiceIdentifier = - createDecorator('modelService'); diff --git a/packages/agent-core-v2/src/kosong/model/model.types.ts b/packages/agent-core-v2/src/kosong/model/model.types.ts deleted file mode 100644 index 816b59e1e..000000000 --- a/packages/agent-core-v2/src/kosong/model/model.types.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ModelCapability } from '#/kosong/contract/capability'; - -import type { OAuthRef } from '../provider/provider'; - -export interface ModelOverrides { - readonly temperature?: number; - readonly topP?: number; - readonly thinkingKeep?: string; - readonly maxCompletionTokens?: number; -} - -export interface CompletionBudgetConfig { - readonly hardCap?: number; - readonly fallback?: number; -} - -export interface CompletionBudgetParams { - readonly maxCompletionTokens: number; - readonly usedContextTokens?: number; - readonly maxContextTokens?: number; -} - -export interface ResolvedModelAuthMaterial { - readonly apiKey?: string; - readonly oauth?: OAuthRef; - readonly oauthProviderKey?: string; -} - -export interface ThinkingDefaults { - readonly enabled?: boolean; - readonly effort?: string; -} - -export interface ModelThinkingMetadata { - readonly capabilities?: ModelCapability | readonly string[]; - readonly adaptiveThinking?: boolean; - readonly alwaysThinking?: boolean; - readonly supportEfforts?: readonly string[]; - readonly defaultEffort?: string; -} diff --git a/packages/agent-core-v2/src/kosong/model/modelAuth.ts b/packages/agent-core-v2/src/kosong/model/modelAuth.ts deleted file mode 100644 index 095db38d4..000000000 --- a/packages/agent-core-v2/src/kosong/model/modelAuth.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { Error2 } from '#/_base/errors/errors'; -import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors'; -import type { InspectionSource, ResolutionTrace } from '#/kosong/contract/inspection'; -import { ProtocolSchema, type Protocol } from '#/kosong/protocol/protocol'; - -import { - BUDGET_THINKING_EFFORTS, - matchKnownAnthropicModelProfile, - matchUnknownClaudeProfile, -} from '../provider/bases/anthropic/anthropic-profile'; -import type { ProviderConfig } from '../provider/provider'; -import { explainProviderEndpoint, getProviderDefinition } from '../provider/providerDefinition'; - -import type { ModelRecord } from './model'; -import type { ResolvedModelAuthMaterial } from './model.types'; -import { drivesThinkingThroughTraits } from './thinking'; - -export function resolveModelAuthMaterial( - args: { - readonly modelId: string; - readonly model: ModelRecord; - readonly provider: ProviderConfig | undefined; - readonly providerName: string; - }, - trace?: ResolutionTrace, -): ResolvedModelAuthMaterial { - const modelApiKey = nonEmpty(args.model.apiKey); - if (modelApiKey !== undefined && args.model.oauth !== undefined) { - throw authConflictError('Model', args.modelId); - } - if (modelApiKey !== undefined) { - trace?.record('resolved.auth', { kind: 'config', detail: 'model.apiKey' }); - return { apiKey: modelApiKey }; - } - if (args.model.oauth !== undefined) { - trace?.record('resolved.auth', { kind: 'config', detail: 'model.oauth' }); - return { - oauth: args.model.oauth, - oauthProviderKey: args.model.providerId ?? args.model.provider, - }; - } - - const providerAuthType = args.provider?.type ?? args.model.protocol; - const providerEndpoint = - providerAuthType === undefined - ? {} - : explainProviderEndpoint(providerAuthType, args.provider?.env ?? {}); - const providerApiKey = nonEmpty(args.provider?.apiKey) ?? nonEmpty(providerEndpoint.apiKey); - if (providerApiKey !== undefined && args.provider?.oauth !== undefined) { - throw authConflictError('Provider', args.providerName); - } - if (providerApiKey !== undefined) { - trace?.record( - 'resolved.auth', - nonEmpty(args.provider?.apiKey) !== undefined - ? { kind: 'config', detail: `provider '${args.providerName}' apiKey` } - : { - kind: 'env', - detail: `${providerEndpoint.apiKeyEnvName ?? '?'} (provider '${args.providerName}' env bag)`, - }, - ); - return { apiKey: providerApiKey }; - } - if (args.provider?.oauth !== undefined) { - trace?.record('resolved.auth', { - kind: 'config', - detail: `provider '${args.providerName}' oauth`, - }); - return { - oauth: args.provider.oauth, - oauthProviderKey: args.model.providerId ?? args.model.provider, - }; - } - trace?.record('resolved.auth', { - kind: 'none', - detail: 'no credential resolved at any layer (adapter construction may still read process.env)', - }); - return {}; -} - -export function effectiveModelConfig( - model: ModelRecord, - providerType?: string, -): ModelRecord { - const { overrides, ...base } = model; - let effective: ModelRecord = overrides === undefined ? model : { ...base, ...overrides }; - if ( - overrides?.supportEfforts !== undefined && - overrides.defaultEffort === undefined && - effective.defaultEffort !== undefined && - !overrides.supportEfforts.includes(effective.defaultEffort) - ) { - const { defaultEffort: _defaultEffort, ...withoutDefaultEffort } = effective; - effective = withoutDefaultEffort; - } - const clamped = - effective.maxInputSize !== undefined && - effective.maxContextSize !== undefined && - effective.maxInputSize > effective.maxContextSize - ? { ...effective, maxInputSize: effective.maxContextSize } - : effective; - return withAnthropicProfile(clamped, providerType); -} - -function withAnthropicProfile(model: ModelRecord, providerType?: string): ModelRecord { - const wireName = model.name ?? model.model; - const protocol = model.protocol ?? providerType; - const profile = - wireName === undefined - ? undefined - : providerType !== undefined && !drivesThinkingThroughTraits(providerType) && protocol === 'anthropic' - ? (matchKnownAnthropicModelProfile(wireName) ?? matchUnknownClaudeProfile(wireName)) - : matchKnownAnthropicModelProfile(wireName); - if (profile === undefined) return model; - const capability = profile.canDisableThinking ? 'thinking' : 'always_thinking'; - const capabilities = model.capabilities ?? []; - const hasCapability = capabilities.some( - (candidate) => candidate.trim().toLowerCase() === capability, - ); - const supportEfforts = - model.supportEfforts ?? - (model.adaptiveThinking === false ? [...BUDGET_THINKING_EFFORTS] : [...profile.efforts]); - return { - ...model, - capabilities: hasCapability ? capabilities : [...capabilities, capability], - supportEfforts, - defaultEffort: - model.defaultEffort ?? (supportEfforts.includes('high') ? 'high' : undefined), - }; -} - -export function deriveProviderId(baseUrl: string): string { - try { - const url = new URL(baseUrl); - return url.host; - } catch { - return baseUrl; - } -} - -export function providerNameFromFlatModel(model: ModelRecord): string | undefined { - const baseUrl = nonEmpty(model.baseUrl); - return baseUrl === undefined ? undefined : deriveProviderId(baseUrl); -} - -export interface ModelProtocolResolution { - readonly protocol: Protocol; - readonly source: InspectionSource; -} - -export function resolveModelProtocol( - model: ModelRecord, - provider: ProviderConfig | undefined, -): ModelProtocolResolution | undefined { - if (model.protocol !== undefined) { - return { protocol: model.protocol, source: { kind: 'config', detail: 'model.protocol' } }; - } - const providerType = provider?.type; - if (providerType === undefined) return undefined; - const protocol = ProtocolSchema.safeParse(providerType); - if (protocol.success) { - return { - protocol: protocol.data, - source: { - kind: 'config', - detail: `provider type '${providerType}' is itself a wire protocol`, - }, - }; - } - const definition = getProviderDefinition(providerType); - if (definition === undefined) return undefined; - return { - protocol: definition.baseProtocol, - source: { kind: 'builtin', detail: `vendor '${providerType}' declared baseProtocol` }, - }; -} - -export type ModelReadyFailureReason = - | 'no-default' - | 'dangling-alias' - | 'provider-missing' - | 'unresolvable'; - -export type ModelReadyResolution = - | { readonly resolved: true } - | { readonly resolved: false; readonly reason: ModelReadyFailureReason }; - -export function resolveModelForReady( - modelId: string | undefined, - models: Readonly>, - providers: Readonly>, - defaultProvider?: string, -): ModelReadyResolution { - if (modelId === undefined || modelId.trim().length === 0) { - return { resolved: false, reason: 'no-default' }; - } - const configured = models[modelId]; - if (configured === undefined) { - return { resolved: false, reason: 'dangling-alias' }; - } - const model = effectiveModelConfig(configured); - const fallbackProvider = - defaultProvider === undefined || defaultProvider.trim().length === 0 - ? undefined - : defaultProvider; - const providerId = model.providerId ?? model.provider ?? fallbackProvider; - const provider = providerId === undefined ? undefined : providers[providerId]; - if (providerId !== undefined && provider === undefined) { - return { resolved: false, reason: 'provider-missing' }; - } - if (providerId === undefined && providerNameFromFlatModel(model) === undefined) { - return { resolved: false, reason: 'unresolvable' }; - } - if (nonEmpty(model.name ?? model.model) === undefined) { - return { resolved: false, reason: 'unresolvable' }; - } - if (model.maxContextSize === undefined || model.maxContextSize <= 0) { - return { resolved: false, reason: 'unresolvable' }; - } - if (resolveModelProtocol(model, provider) === undefined) { - return { resolved: false, reason: 'unresolvable' }; - } - return { resolved: true }; -} - -export function nonEmpty(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; -} - -function authConflictError(kind: string, name: string): Error2 { - return new Error2( - CONFIG_INVALID_ERROR_CODE, - `${kind} "${name}" has both apiKey and oauth set in config.toml - they are mutually exclusive. Remove one.`, - ); -} diff --git a/packages/agent-core-v2/src/kosong/model/modelOAuth.ts b/packages/agent-core-v2/src/kosong/model/modelOAuth.ts deleted file mode 100644 index 562d00219..000000000 --- a/packages/agent-core-v2/src/kosong/model/modelOAuth.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; - -import type { OAuthRef } from '../provider/provider'; - -export interface IModelOAuthTokens { - readonly _serviceBrand: undefined; - - hasCachedAccessToken(provider: string, oauthRef: OAuthRef): Promise; - getAccessToken( - provider: string, - oauthRef: OAuthRef, - options?: { readonly force?: boolean }, - ): Promise; -} - -export const IModelOAuthTokens: ServiceIdentifier = - createDecorator('modelOAuthTokens'); diff --git a/packages/agent-core-v2/src/kosong/model/modelRequester.ts b/packages/agent-core-v2/src/kosong/model/modelRequester.ts deleted file mode 100644 index 9591ab926..000000000 --- a/packages/agent-core-v2/src/kosong/model/modelRequester.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Message, StreamedMessagePart, VideoURLPart } from '#/kosong/contract/message'; -import type { - FinishReason, - ResponseFormat, - SamplingOptions, - ThinkingEffort, - VideoUploadInput, -} from '#/kosong/contract/provider'; -import type { Tool } from '#/kosong/contract/tool'; -import type { TokenUsage } from '#/kosong/contract/usage'; - -import type { Model } from './catalog'; - -export interface ModelRequestInput { - readonly systemPrompt: string; - readonly tools: readonly Tool[]; - readonly messages: readonly Message[]; - readonly responseFormat?: ResponseFormat; -} - -export interface ModelRequestTiming { - readonly firstTokenLatencyMs: number; - readonly streamDurationMs: number; - readonly requestBuildMs?: number; - readonly serverFirstTokenMs?: number; - readonly serverDecodeMs?: number; - readonly clientConsumeMs?: number; - readonly clientBlockedMs?: number; -} - -export type ModelRequestEvent = - | { readonly type: 'part'; readonly part: StreamedMessagePart } - | { readonly type: 'usage'; readonly usage: TokenUsage; readonly model?: string } - | { - readonly type: 'finish'; - readonly message: Message; - readonly providerFinishReason?: FinishReason; - readonly rawFinishReason?: string; - readonly id?: string; - readonly traceId?: string; - } - | ({ readonly type: 'timing' } & ModelRequestTiming); - -export interface ModelRequestParams { - readonly cacheKey?: string; - readonly conversationId?: string; - readonly sampling?: SamplingOptions; - readonly thinkingEffort?: ThinkingEffort; - readonly thinkingKeep?: string; - readonly maxCompletionTokens?: number; - readonly usedContextTokens?: number; - readonly maxContextTokens?: number; - readonly onTraceId?: (traceId: string | null) => void; -} - -export interface ModelRequester { - readonly model: Model; - - request( - input: ModelRequestInput, - signal?: AbortSignal, - params?: ModelRequestParams, - ): AsyncIterable; - - uploadVideo?( - input: string | VideoUploadInput, - options?: { readonly signal?: AbortSignal }, - ): Promise; -} - -export function effectiveMaxCompletionTokens(params?: ModelRequestParams): number | undefined { - return params?.maxCompletionTokens; -} diff --git a/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts b/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts deleted file mode 100644 index 330670ce9..000000000 --- a/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { AsyncEventQueue } from '#/_base/asyncEventQueue'; -import type { VideoURLPart } from '#/kosong/contract/message'; -import { APIStatusError, isAbortError, VideoUploadUnsupportedError } from '#/kosong/contract/errors'; -import { generate, type GenerateResult } from '#/kosong/contract/generate'; -import type { - ChatProvider, - GenerateOptions, - ProviderRequestAuth, - StreamDecodeStats, - VideoUploadInput, -} from '#/kosong/contract/provider'; -import { translateProviderError } from '#/kosong/protocol/errors'; -import type { IProtocolAdapterRegistry } from '#/kosong/protocol/protocol'; - -import { opencodeSessionHeaders } from '#/llm-adapter/model/opencode-session'; - -import type { AuthProvider, Model } from './catalog'; -import type { - ModelRequestEvent, - ModelRequestInput, - ModelRequestParams, - ModelRequester, - ModelRequestTiming, -} from './modelRequester'; - -export class ModelRequesterImpl implements ModelRequester { - private cachedChatProvider: ChatProvider | undefined; - - constructor( - readonly model: Model, - private readonly protocolRegistry: IProtocolAdapterRegistry, - ) {} - - private resolveChatProvider(): ChatProvider { - if (this.cachedChatProvider !== undefined) return this.cachedChatProvider; - const model = this.model; - this.cachedChatProvider = this.protocolRegistry.createChatProvider({ - protocol: model.protocol, - providerType: model.providerType, - baseUrl: model.baseUrl, - modelName: model.name, - defaultHeaders: model.headers, - providerOptions: model.providerOptions, - }); - return this.cachedChatProvider; - } - - request( - input: ModelRequestInput, - signal?: AbortSignal, - params?: ModelRequestParams, - ): AsyncIterable { - const queue = new AsyncEventQueue(); - void this.runRequest(input, signal, queue, params).then( - () => queue.end(), - (error) => queue.fail(error), - ); - return queue; - } - - async uploadVideo( - input: string | VideoUploadInput, - options?: { readonly signal?: AbortSignal }, - ): Promise { - const provider = this.resolveChatProvider(); - if (provider.uploadVideo === undefined) { - throw new VideoUploadUnsupportedError( - `Model "${this.model.id}" (protocol=${this.model.protocol}) does not support video upload`, - ); - } - const uploadVideo = provider.uploadVideo.bind(provider); - return this.runWithAuthRefresh((auth) => - uploadVideo(input, { signal: options?.signal, auth }), - ); - } - - private async runRequest( - input: ModelRequestInput, - signal: AbortSignal | undefined, - queue: AsyncEventQueue, - params?: ModelRequestParams, - ): Promise { - signal?.throwIfAborted(); - const provider = this.resolveChatProvider(); - - let requestStartedAt = Date.now(); - let requestSentAt: number | undefined; - let firstChunkAt: number | undefined; - let streamEndedAt: number | undefined; - let decodeStats: StreamDecodeStats | undefined; - - const options: GenerateOptions = { - signal, - cacheKey: params?.cacheKey, - extraHeaders: opencodeSessionHeaders( - this.model.baseUrl, - params?.conversationId ?? params?.cacheKey, - ), - sampling: params?.sampling, - thinking: - params?.thinkingEffort === undefined - ? undefined - : { effort: params.thinkingEffort, keep: params.thinkingKeep }, - maxCompletionTokens: params?.maxCompletionTokens, - usedContextTokens: params?.usedContextTokens, - maxContextTokens: params?.maxContextTokens, - onRequestStart: () => { - requestStartedAt = Date.now(); - }, - onRequestSent: () => { - requestSentAt = Date.now(); - }, - onStreamEnd: (stats) => { - streamEndedAt = Date.now(); - decodeStats = stats; - }, - onTraceId: params?.onTraceId, - responseFormat: input.responseFormat, - }; - - let result: GenerateResult; - try { - result = await this.runWithAuthRefresh((auth) => { - requestStartedAt = Date.now(); - return generate( - provider, - input.systemPrompt, - [...input.tools], - [...input.messages], - { - onMessagePart: (part) => { - firstChunkAt ??= Date.now(); - queue.push({ type: 'part', part }); - }, - }, - { ...options, auth }, - ); - }); - } catch (error) { - if (isAbortError(error) || signal?.aborted === true) throw error; - throw translateProviderError(error); - } - - if (result.usage !== undefined && result.usage !== null) { - queue.push({ type: 'usage', usage: result.usage, model: this.model.name }); - } - queue.push({ - type: 'finish', - message: result.message, - providerFinishReason: result.finishReason ?? undefined, - rawFinishReason: result.rawFinishReason ?? undefined, - id: result.id ?? undefined, - traceId: result.traceId ?? undefined, - }); - if (firstChunkAt !== undefined) { - queue.push({ - type: 'timing', - ...buildStreamTiming( - requestStartedAt, - requestSentAt, - firstChunkAt, - streamEndedAt, - decodeStats, - ), - }); - } - } - - private async runWithAuthRefresh( - run: (auth: ProviderRequestAuth | undefined) => Promise, - ): Promise { - const auth = await this.authProvider.getAuth(); - try { - return await run(auth); - } catch (error) { - if (!this.shouldForceRefresh(error)) throw error; - } - - const refreshedAuth = await this.authProvider.getAuth({ force: true }); - try { - return await run(refreshedAuth); - } catch (error) { - if (isUnauthorizedStatusError(error)) throw translateProviderError(error); - throw error; - } - } - - private get authProvider(): AuthProvider { - return this.model.authProvider; - } - - private shouldForceRefresh(error: unknown): boolean { - return this.authProvider.canRefresh === true && isUnauthorizedStatusError(error); - } -} - -function isUnauthorizedStatusError(error: unknown): error is APIStatusError { - return error instanceof APIStatusError && error.statusCode === 401; -} - -type MutableModelRequestTiming = { -readonly [K in keyof ModelRequestTiming]: ModelRequestTiming[K] }; - -export function buildStreamTiming( - requestStartedAt: number, - requestSentAt: number | undefined, - firstChunkAt: number, - streamEndedAt: number | undefined, - decodeStats: StreamDecodeStats | undefined, -): ModelRequestTiming { - const outputEndedAt = streamEndedAt ?? Date.now(); - const timing: MutableModelRequestTiming = { - firstTokenLatencyMs: Math.max(0, firstChunkAt - requestStartedAt), - streamDurationMs: Math.max(0, outputEndedAt - firstChunkAt), - }; - if (requestSentAt !== undefined) { - const sentAt = Math.min(Math.max(requestSentAt, requestStartedAt), firstChunkAt); - timing.requestBuildMs = sentAt - requestStartedAt; - timing.serverFirstTokenMs = firstChunkAt - sentAt; - } - if (decodeStats !== undefined) { - timing.serverDecodeMs = Math.max(0, decodeStats.serverDecodeMs); - timing.clientConsumeMs = Math.max(0, decodeStats.clientConsumeMs); - if (decodeStats.clientBlockedMs !== undefined) { - timing.clientBlockedMs = Math.max(0, decodeStats.clientBlockedMs); - } - } - return timing; -} diff --git a/packages/agent-core-v2/src/kosong/model/modelService.ts b/packages/agent-core-v2/src/kosong/model/modelService.ts deleted file mode 100644 index d6f78b04d..000000000 --- a/packages/agent-core-v2/src/kosong/model/modelService.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { AsyncEmitter, type Event, type IWaitUntil } from '#/_base/event'; - -import { deepEqual, diffRecords, isEmptyDiff } from '../recordDiff'; - -import { resolveDefaultModel } from './defaultModelPolicy'; -import { resolveModelForReady } from './modelAuth'; -import { - type DefaultModelChangedEvent, - IModelService, - type ModelRecord, - type ModelsChangedEvent, - type ModelsSection, -} from './model'; -import { IProviderService } from '../provider/provider'; -import { IEventService } from '../../app/event/event'; -import { ConfigWarning } from '../../app/config/configEvents'; - -const NO_ABORT = new AbortController().signal; - -export class ModelService extends Disposable implements IModelService { - declare readonly _serviceBrand: undefined; - - private models: Readonly> = {}; - private defaultModel: string | undefined; - private lastUsedModel: string | undefined; - private settling: Promise = Promise.resolve(); - private hydrated = false; - private resolveReady!: () => void; - readonly ready: Promise = new Promise((resolve) => { - this.resolveReady = resolve; - }); - - constructor( - @IProviderService private readonly providers: IProviderService, - @IEventService private readonly events: IEventService, - ) { - super(); - } - - private readonly _onDidChangeModels = this._register( - new AsyncEmitter(), - ); - readonly onDidChangeModels: Event = - this._onDidChangeModels.event; - private readonly _onDidChangeDefaultModel = this._register( - new AsyncEmitter(), - ); - readonly onDidChangeDefaultModel: Event = - this._onDidChangeDefaultModel.event; - private readonly _onDidChangeLastUsedModel = this._register( - new AsyncEmitter(), - ); - readonly onDidChangeLastUsedModel: Event = - this._onDidChangeLastUsedModel.event; - - get settled(): Promise { - return this.settling; - } - - get(id: string): ModelRecord | undefined { - return this.models[id]; - } - - list(): Readonly> { - return this.models; - } - - getDefaultModel(): string | undefined { - return this.defaultModel; - } - - getLastUsedModel(): string | undefined { - return this.lastUsedModel; - } - - loadAll(models: ModelsSection, defaultModel: string | undefined, lastUsedModel: string | undefined): void { - void this.applyRecords(models); - void this.applyDefaultModel(defaultModel); - void this.applyLastUsedModel(lastUsedModel); - this.settling = this.settleDefaultModel(); - if (!this.hydrated) { - this.hydrated = true; - this.resolveReady(); - } - } - - async replaceAll(models: ModelsSection): Promise { - await this.ready; - await this.applyRecords(models); - await this.settleDefaultModel(); - } - - async set(id: string, model: ModelRecord): Promise { - await this.ready; - if (deepEqual(this.models[id], model)) return; - await this.applyRecords({ ...this.models, [id]: model }); - await this.settleDefaultModel(); - } - - async delete(id: string): Promise { - await this.ready; - if (!(id in this.models)) return; - const { [id]: _removed, ...rest } = this.models; - await this.applyRecords(rest); - await this.settleDefaultModel(); - } - - async setDefaultModel(id: string | undefined): Promise { - await this.ready; - await this.applyDefaultModel(id); - } - - async setLastUsedModel(id: string | undefined): Promise { - await this.ready; - await this.applyLastUsedModel(id); - } - - private settleDefaultModel(): Promise { - const current = this.defaultModel; - const lastUsed = this.lastUsedModel; - const resolve = (id: string) => - resolveModelForReady(id, this.models, this.providers.list(), this.providers.getDefaultProvider()); - const next = resolveDefaultModel( - this.models, - current, - (id) => resolve(id).resolved, - lastUsed, - ); - const currentResolution = current === undefined ? undefined : resolve(current); - if ( - currentResolution !== undefined && - currentResolution.resolved === false && - next !== current - ) { - this.events.publish( - new ConfigWarning({ - payload: { - warnings: [ - { - domain: 'default_model', - message: `Default model "${current}" is no longer available (${currentResolution.reason}); switched to "${ - next ?? 'none' - }".`, - }, - ], - }, - }), - ); - } - const settle = this.applyDefaultModel(next); - this.settling = settle; - return settle; - } - - private async applyRecords(next: Readonly>): Promise { - const diff = diffRecords(this.models, next); - if (isEmptyDiff(diff)) return; - this.models = { ...next }; - await this._onDidChangeModels.fireAsync(diff, NO_ABORT); - } - - private async applyDefaultModel(id: string | undefined): Promise { - if (this.defaultModel === id) return; - this.defaultModel = id; - await this._onDidChangeDefaultModel.fireAsync({ id }, NO_ABORT); - } - - private async applyLastUsedModel(id: string | undefined): Promise { - if (this.lastUsedModel === id) return; - this.lastUsedModel = id; - await this._onDidChangeLastUsedModel.fireAsync({ id }, NO_ABORT); - } -} - -registerScopedService(LifecycleScope.App, IModelService, ModelService, ScopeActivation.OnScopeCreated, 'model'); diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/kosong/model/thinking.ts deleted file mode 100644 index a423ccfa3..000000000 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ /dev/null @@ -1,218 +0,0 @@ -import type { ThinkingEffort } from '#/kosong/contract/provider'; -import type { IProtocolAdapterRegistry, Protocol } from '#/kosong/protocol/protocol'; - -import { getProviderDefinitions } from '../provider/providerDefinition'; - -import type { ModelThinkingMetadata, ThinkingDefaults } from './model.types'; - -export interface ThinkingConfig { - enabled?: boolean; - effort?: string; - forcedEffort?: string; - keep?: string; -} - -export function drivesThinkingThroughTraits(providerType: string | undefined): boolean { - if (providerType === undefined) return false; - return getProviderDefinitions(providerType).some((definition) => - definition.traits.some((trait) => trait.withThinking !== undefined), - ); -} - -export function usesTraitDrivenThinking( - registry: IProtocolAdapterRegistry, - protocol: Protocol, - providerType?: string, -): boolean { - return registry - .resolveAdapterIdentity(protocol, providerType) - .traits.some(({ trait }) => trait.withThinking !== undefined); -} - -export function requiresStrictThinkingValidation( - registry: IProtocolAdapterRegistry, - protocol: Protocol, - providerType?: string, -): boolean { - if (providerType === undefined) return false; - const traits = registry.resolveAdapterIdentity(protocol, providerType).traits; - let strict = false; - for (const { trait } of traits) { - if (trait.withThinking !== undefined) { - strict = trait.strictThinkingValidation === true; - } - } - return strict; -} - -export function wireHasProtocolThinkingDisable(protocol: string | undefined): boolean { - return protocol === 'anthropic' || protocol === 'pythinker'; -} - -function nonEmpty(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; -} - -export function normalizeRequestedThinkingEffort( - requested: string | undefined, -): ThinkingEffort | undefined { - return nonEmpty(requested)?.toLowerCase() as ThinkingEffort | undefined; -} - -export function resolveForcedThinkingEffort( - forced: string | undefined, - effective: ThinkingEffort, - traitDriven: boolean, -): ThinkingEffort | undefined { - if (!traitDriven || effective === 'off') return undefined; - return nonEmpty(forced)?.toLowerCase() as ThinkingEffort | undefined; -} - -function hasCapability( - capabilities: ModelThinkingMetadata['capabilities'], - capability: string, -): boolean { - if (capabilities === undefined) return false; - if (isCapabilityList(capabilities)) { - return capabilities.some((candidate) => candidate.trim().toLowerCase() === capability); - } - switch (capability) { - case 'thinking': - return capabilities.thinking; - case 'always_thinking': - return false; - default: - return false; - } -} - -function isCapabilityList( - capabilities: ModelThinkingMetadata['capabilities'], -): capabilities is readonly string[] { - return Array.isArray(capabilities); -} - -function middleOf(values: readonly string[]): string { - return values[Math.floor(values.length / 2)]!; -} - -function effortsFor(model: ModelThinkingMetadata | undefined): readonly string[] { - return model?.supportEfforts?.map(nonEmpty).filter((v): v is string => v !== undefined) ?? []; -} - -export function modelSupportsThinking(model: ModelThinkingMetadata | undefined): boolean { - if (model === undefined) return false; - return ( - model.alwaysThinking === true || - model.adaptiveThinking === true || - hasCapability(model.capabilities, 'thinking') || - hasCapability(model.capabilities, 'always_thinking') - ); -} - -export function defaultThinkingEffortForModel( - model: ModelThinkingMetadata | undefined, -): ThinkingEffort { - if (model === undefined || !modelSupportsThinking(model)) return 'off'; - const efforts = effortsFor(model); - if (efforts.length > 0) { - const declaredDefault = nonEmpty(model.defaultEffort); - return (declaredDefault !== undefined && efforts.includes(declaredDefault) - ? declaredDefault - : middleOf(efforts)) as ThinkingEffort; - } - return 'on'; -} - -export function declaredDefaultEffortForModel( - model: ModelThinkingMetadata | undefined, -): ThinkingEffort | undefined { - if (!modelSupportsThinking(model)) return undefined; - const declared = nonEmpty(model?.defaultEffort); - if (declared === undefined) return undefined; - return effortsFor(model).includes(declared) ? (declared as ThinkingEffort) : undefined; -} - -export function modelSupportsThinkingEffort( - effort: ThinkingEffort, - model: ModelThinkingMetadata | undefined, - strictValidation: boolean, -): boolean { - if (!strictValidation || effort === 'off') return true; - if (!modelSupportsThinking(model)) return false; - const efforts = effortsFor(model); - return efforts.length === 0 || effort === 'on' || efforts.includes(effort); -} - -function normalizeThinkingEffortForModel( - effort: ThinkingEffort, - model: ModelThinkingMetadata | undefined, - strictValidation: boolean, -): ThinkingEffort { - if (effort === 'off' && model?.alwaysThinking !== true) return 'off'; - const efforts = effortsFor(model); - if (!strictValidation) { - return effort === 'on' && efforts.length > 0 - ? defaultThinkingEffortForModel(model) - : effort; - } - if (!modelSupportsThinking(model)) return 'off'; - if (efforts.length === 0) return 'on'; - if (effort === 'on' || !efforts.includes(effort)) { - return defaultThinkingEffortForModel(model); - } - return effort; -} - -export function resolveThinkingEffortForModel( - requested: string | undefined, - defaults: ThinkingDefaults | undefined, - model: ModelThinkingMetadata | undefined, - strictValidation = false, -): ThinkingEffort { - const configured = normalizeRequestedThinkingEffort(defaults?.effort); - const normalized = normalizeRequestedThinkingEffort(requested); - let effort: ThinkingEffort; - if (normalized !== undefined) { - effort = normalized; - } else if (defaults?.enabled === false) { - effort = 'off'; - } else { - effort = configured ?? defaultThinkingEffortForModel(model); - } - - if (effort === 'off' && model?.alwaysThinking === true) { - effort = - configured !== undefined && configured !== 'off' - ? configured - : defaultThinkingEffortForModel(model); - } - return normalizeThinkingEffortForModel(effort, model, strictValidation); -} - -const KEEP_OFF_VALUES = new Set(['0', 'false', 'no', 'off', 'none', 'null']); - -type KeepResolution = - | { readonly specified: false } - | { readonly specified: true; readonly value: string | undefined }; - -function parseKeepValue(raw: string | undefined): KeepResolution { - const trimmed = raw?.trim(); - if (trimmed === undefined || trimmed.length === 0) return { specified: false }; - if (KEEP_OFF_VALUES.has(trimmed.toLowerCase())) return { specified: true, value: undefined }; - return { specified: true, value: trimmed }; -} - -export function resolveThinkingKeep( - envKeep: string | undefined, - configKeep: string | undefined, - thinkingEffort: ThinkingEffort, -): string | undefined { - if (thinkingEffort === 'off') return undefined; - const fromEnv = parseKeepValue(envKeep); - if (fromEnv.specified) return fromEnv.value; - const fromConfig = parseKeepValue(configKeep); - if (fromConfig.specified) return fromConfig.value; - return 'all'; -} diff --git a/packages/agent-core-v2/src/kosong/protocol/errors.ts b/packages/agent-core-v2/src/kosong/protocol/errors.ts deleted file mode 100644 index 9c032d268..000000000 --- a/packages/agent-core-v2/src/kosong/protocol/errors.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - ProtocolErrors, - sanitizeStatusErrorMessage, - translateProviderError, -} from '#/llm-adapter/protocol/errors'; diff --git a/packages/agent-core-v2/src/kosong/protocol/protocol.ts b/packages/agent-core-v2/src/kosong/protocol/protocol.ts deleted file mode 100644 index b0c38f90a..000000000 --- a/packages/agent-core-v2/src/kosong/protocol/protocol.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { z } from 'zod'; - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { InspectionSource } from '#/kosong/contract/inspection'; -import type { ChatProvider } from '#/kosong/contract/provider'; - -import type { ProtocolBaseId, ResolvedAdapterIdentity } from './protocolBase'; - -export const ProtocolSchema = z.enum([ - 'anthropic', - 'openai', - 'openai_responses', - 'google-genai', -]); - -export type Protocol = z.infer; - -export interface ProtocolProviderOptions { - readonly reasoningKey?: string; - readonly defaultMaxTokens?: number; - readonly supportEfforts?: readonly string[]; - readonly offEffort?: string; - readonly adaptiveThinking?: boolean; - readonly betaApi?: boolean; - readonly metadata?: Readonly>; - readonly vertexai?: boolean; - readonly project?: string; - readonly location?: string; -} - -export interface ProtocolAdapterConfig { - readonly protocol: Protocol; - readonly providerType?: string; - readonly baseUrl?: string; - readonly modelName: string; - readonly apiKey?: string; - readonly defaultHeaders?: Readonly>; - readonly providerOptions?: ProtocolProviderOptions; -} - -export interface ExplainedCapability { - readonly capability: ModelCapability; - readonly source: InspectionSource; -} - -export interface IProtocolAdapterRegistry { - readonly _serviceBrand: undefined; - - supportedProtocols(): readonly Protocol[]; - - resolveAdapterIdentity(protocol: Protocol, providerType?: string): ResolvedAdapterIdentity; - - resolveProviderBaseId(protocol: Protocol, providerType?: string): ProtocolBaseId; - - resolveCapability( - protocol: Protocol, - modelName: string, - providerType?: string, - ): ModelCapability; - - explainCapability( - protocol: Protocol, - modelName: string, - providerType?: string, - ): ExplainedCapability; - - createChatProvider(config: ProtocolAdapterConfig): ChatProvider; -} - -export const IProtocolAdapterRegistry: ServiceIdentifier = - createDecorator('protocolAdapterRegistry'); diff --git a/packages/agent-core-v2/src/kosong/protocol/protocolBase.ts b/packages/agent-core-v2/src/kosong/protocol/protocolBase.ts deleted file mode 100644 index 199800346..000000000 --- a/packages/agent-core-v2/src/kosong/protocol/protocolBase.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { BugIndicatingError } from '#/_base/errors/errors'; -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { ChatProvider } from '#/kosong/contract/provider'; - -import type { Protocol, ProtocolAdapterConfig } from './protocol'; -import type { ResolvedTrait } from './protocolTrait'; - -export type ProtocolBaseId = Protocol; - -export interface ProtocolBaseContext { - readonly config: ProtocolAdapterConfig; - readonly traits: readonly ResolvedTrait[]; -} - -export interface ProtocolBaseDefinition { - readonly id: ProtocolBaseId; - capability?(modelName: string): ModelCapability | undefined; - createChatProvider(context: ProtocolBaseContext): ChatProvider; -} - -export interface ResolvedAdapterIdentity { - readonly baseId: ProtocolBaseId; - readonly traits: readonly ResolvedTrait[]; -} - -const protocolBases = new Map(); - -export function registerProtocolBase(definition: ProtocolBaseDefinition): void { - if (protocolBases.has(definition.id)) { - throw new BugIndicatingError(`protocol base '${definition.id}' is already registered`); - } - protocolBases.set(definition.id, definition); -} - -export function getProtocolBase(id: ProtocolBaseId): ProtocolBaseDefinition | undefined { - return protocolBases.get(id); -} - -export function listProtocolBases(): readonly ProtocolBaseDefinition[] { - return [...protocolBases.values()]; -} diff --git a/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts b/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts deleted file mode 100644 index 3670b4a4c..000000000 --- a/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { ChatProviderError } from '#/kosong/contract/errors'; -import type { Message, VideoURLPart } from '#/kosong/contract/message'; -import type { - GenerateOptions, - ThinkingEffort, - ToolCallIdPolicy, - VideoUploadInput, -} from '#/kosong/contract/provider'; -import type { Tool } from '#/kosong/contract/tool'; - -import type { ProtocolAdapterConfig } from './protocol'; - -export interface TraitContext { - readonly config: ProtocolAdapterConfig; - readonly providerId?: string; -} - -export interface ProtocolEndpoint { - readonly apiKeyEnv?: string; - readonly baseUrlEnv?: string; - readonly defaultBaseUrl?: string; -} - -export interface ProtocolTrait { - readonly strictThinkingValidation?: boolean; - - provides?(ctx: TraitContext): Record | undefined; - - endpoint?(ctx: TraitContext): ProtocolEndpoint | undefined; - - defaultHeaders?(ctx: TraitContext): Record | undefined; - - convertTool?(tool: Tool, ctx: TraitContext): Record | undefined; - - convertMessage?( - message: Message, - converted: Record, - ctx: TraitContext, - ): Record | null; - - mergeHistory?( - messages: readonly Record[], - ctx: TraitContext, - ): Record[] | undefined; - - buildParams?( - params: Record, - ctx: TraitContext, - ): Record | undefined; - - toolCallIdPolicy?(ctx: TraitContext): ToolCallIdPolicy | undefined; - - convertError?(error: unknown, ctx: TraitContext): ChatProviderError | undefined; - - withThinking?( - effort: ThinkingEffort, - options: { readonly keep?: string }, - generationKwargs: Record, - ctx: TraitContext, - ): Record | undefined; - - preserveThinking?( - generationKwargs: Record, - ctx: TraitContext, - ): boolean | undefined; - - withMaxCompletionTokens?( - maxCompletionTokens: number, - ctx: TraitContext, - ): Record | undefined; - - cacheKey?(key: string, ctx: TraitContext): Record | undefined; - - extractUsage?( - chunk: Record, - ctx: TraitContext, - ): Record | null | undefined; - - reasoningKey?(ctx: TraitContext): string | undefined; - - capability?(modelName: string, ctx: TraitContext): ModelCapability | undefined; - - uploadVideo?( - input: string | VideoUploadInput, - options: GenerateOptions | undefined, - ctx: TraitContext, - ): Promise; -} - -export interface ResolvedTrait { - readonly trait: ProtocolTrait; - readonly context: TraitContext; -} - -export function traitDefaultHeaders( - traits: readonly ResolvedTrait[], -): Record | undefined { - let headers: Record | undefined; - for (const { trait, context } of traits) { - if (trait.defaultHeaders === undefined) continue; - const declared = trait.defaultHeaders(context); - if (declared === undefined) continue; - headers = { ...headers, ...declared }; - } - return headers; -} - -export function traitConvertError( - traits: readonly ResolvedTrait[], -): ((error: unknown) => ChatProviderError | undefined) | undefined { - let bound: ((error: unknown) => ChatProviderError | undefined) | undefined; - for (const { trait, context } of traits) { - if (trait.convertError === undefined) continue; - const declared = trait.convertError.bind(trait); - bound = (error) => declared(error, context); - } - return bound; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic-profile.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic-profile.ts deleted file mode 100644 index 3f73e9742..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic-profile.ts +++ /dev/null @@ -1,148 +0,0 @@ -export type AnthropicThinkingMode = 'budget' | 'adaptive'; - -export interface AnthropicModelProfile { - readonly mode: AnthropicThinkingMode; - readonly efforts: readonly string[]; - readonly supportsEffortParam: boolean; - readonly canDisableThinking: boolean; -} - -export type AnthropicModelFamily = 'opus' | 'sonnet' | 'haiku' | 'fable' | 'mythos'; - -export interface AnthropicModelVersion { - readonly family: AnthropicModelFamily; - readonly major: number; - readonly minor: number | null; -} - -export const BUDGET_THINKING_EFFORTS = ['low', 'medium', 'high'] as const; -const ADAPTIVE_MAX_EFFORTS = ['low', 'medium', 'high', 'max'] as const; -export const LATEST_OPUS_THINKING_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const; - -const BUDGET_PROFILE: AnthropicModelProfile = { - mode: 'budget', - efforts: BUDGET_THINKING_EFFORTS, - supportsEffortParam: false, - canDisableThinking: true, -}; - -const OPUS_45_PROFILE: AnthropicModelProfile = { - ...BUDGET_PROFILE, - supportsEffortParam: true, -}; - -const ADAPTIVE_MAX_PROFILE: AnthropicModelProfile = { - mode: 'adaptive', - efforts: ADAPTIVE_MAX_EFFORTS, - supportsEffortParam: true, - canDisableThinking: true, -}; - -export const LATEST_OPUS_PROFILE: AnthropicModelProfile = { - mode: 'adaptive', - efforts: LATEST_OPUS_THINKING_EFFORTS, - supportsEffortParam: true, - canDisableThinking: true, -}; - -const ALWAYS_ADAPTIVE_PROFILE: AnthropicModelProfile = { - ...LATEST_OPUS_PROFILE, - canDisableThinking: false, -}; - -const ALWAYS_ADAPTIVE_MAX_PROFILE: AnthropicModelProfile = { - ...ADAPTIVE_MAX_PROFILE, - canDisableThinking: false, -}; - -const FAMILY_FIRST_RE = - /(opus|sonnet|haiku|fable|mythos)[-._](\d{1,2})(?!\d)(?:[-._](\d{1,2})(?!\d))?/; -const VERSION_FIRST_RE = /(\d{1,2})[-._](\d{1,2})[-._](opus|sonnet|haiku)/; -const BARE_FAMILY_RE = /(\d{1,2})[-._](opus|sonnet|haiku)/; - -export function parseAnthropicModelVersion( - model: string, - requireClaudeMarker = false, -): AnthropicModelVersion | null { - const normalized = model.toLowerCase(); - if (requireClaudeMarker && !normalized.includes('claude')) return null; - - const familyFirst = FAMILY_FIRST_RE.exec(normalized); - if (familyFirst !== null) { - return { - family: familyFirst[1] as AnthropicModelFamily, - major: Number.parseInt(familyFirst[2]!, 10), - minor: familyFirst[3] !== undefined ? Number.parseInt(familyFirst[3]!, 10) : null, - }; - } - - const versionFirst = VERSION_FIRST_RE.exec(normalized); - if (versionFirst !== null) { - return { - major: Number.parseInt(versionFirst[1]!, 10), - minor: Number.parseInt(versionFirst[2]!, 10), - family: versionFirst[3] as AnthropicModelFamily, - }; - } - - const bare = BARE_FAMILY_RE.exec(normalized); - if (bare !== null) { - return { - major: Number.parseInt(bare[1]!, 10), - minor: null, - family: bare[2] as AnthropicModelFamily, - }; - } - - return null; -} - -export function matchKnownAnthropicModelProfile(model: string): AnthropicModelProfile | undefined { - const normalized = model.toLowerCase(); - if (/mythos[-._]preview/.test(normalized)) return ALWAYS_ADAPTIVE_MAX_PROFILE; - - const version = parseAnthropicModelVersion(model); - if (version === null) return undefined; - - switch (version.family) { - case 'opus': - if (version.major === 4 && (version.minor === 7 || version.minor === 8)) { - return LATEST_OPUS_PROFILE; - } - if (version.major === 4 && version.minor === 6) return ADAPTIVE_MAX_PROFILE; - if (version.major === 4 && version.minor === 5) return OPUS_45_PROFILE; - if (version.major < 4 || (version.major === 4 && (version.minor ?? 0) < 5)) { - return BUDGET_PROFILE; - } - return undefined; - case 'sonnet': - if (version.major === 5) return LATEST_OPUS_PROFILE; - if (version.major === 4 && version.minor === 6) return ADAPTIVE_MAX_PROFILE; - if (version.major < 4 || (version.major === 4 && (version.minor ?? 0) <= 5)) { - return BUDGET_PROFILE; - } - return undefined; - case 'haiku': - if (version.major < 4 || (version.major === 4 && (version.minor ?? 0) <= 5)) { - return BUDGET_PROFILE; - } - return undefined; - case 'fable': - return version.major === 5 ? ALWAYS_ADAPTIVE_PROFILE : undefined; - case 'mythos': - return version.major === 5 ? ALWAYS_ADAPTIVE_PROFILE : undefined; - } -} - -export function inferAnthropicModelProfile(model: string): AnthropicModelProfile { - return matchKnownAnthropicModelProfile(model) ?? LATEST_OPUS_PROFILE; -} - -export function matchUnknownClaudeProfile(model: string): AnthropicModelProfile | undefined { - const normalized = model.toLowerCase(); - return normalized.includes('claude') || CLAUDE_FAMILY_WORD_RE.test(normalized) - ? LATEST_OPUS_PROFILE - : undefined; -} - -const CLAUDE_FAMILY_WORD_RE = /\b(?:opus|sonnet|haiku|fable|mythos)\b/; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts deleted file mode 100644 index 64fc2d70b..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { registerProtocolBase } from '#/kosong/protocol/protocolBase'; -import { traitDefaultHeaders } from '#/kosong/protocol/protocolTrait'; - -import { AnthropicChatProvider, getAnthropicModelCapability } from './anthropic'; -import { composeAnthropicHooks } from './anthropicHooks'; -import { compactObject, firstProcessEnv, traitEndpoint, traitProvides } from '../openai/openaiHooks'; - -registerProtocolBase({ - id: 'anthropic', - capability: getAnthropicModelCapability, - createChatProvider({ config, traits }) { - const endpoint = traitEndpoint(traits); - return new AnthropicChatProvider({ - ...(traitProvides(traits) as Partial[0]>), - model: config.modelName, - ...compactObject({ - apiKey: config.apiKey ?? firstProcessEnv(endpoint?.apiKeyEnv), - baseUrl: - config.baseUrl ?? firstProcessEnv(endpoint?.baseUrlEnv) ?? endpoint?.defaultBaseUrl, - defaultHeaders: traitDefaultHeaders(traits), - defaultMaxTokens: config.providerOptions?.defaultMaxTokens, - adaptiveThinking: config.providerOptions?.adaptiveThinking, - supportEfforts: config.providerOptions?.supportEfforts, - betaApi: config.providerOptions?.betaApi, - metadata: - config.providerOptions?.metadata === undefined - ? undefined - : { ...config.providerOptions.metadata }, - hooks: composeAnthropicHooks(traits), - }), - }); - }, -}); diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts deleted file mode 100644 index cab0caec5..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts +++ /dev/null @@ -1,1188 +0,0 @@ -import Anthropic, { - APIError as AnthropicAPIError, - APIConnectionError as AnthropicConnectionError, - AnthropicError, - APIConnectionTimeoutError as AnthropicTimeoutError, -} from '@anthropic-ai/sdk'; -import type { - Tool as AnthropicTool, - ContentBlockParam, - MessageCreateParams, - MessageCreateParamsStreaming, - MessageParam, - MessageStreamEvent, - RawContentBlockDeltaEvent, - RawContentBlockStartEvent, - RawMessageStartEvent, - TextBlockParam, - ThinkingBlockParam, - ToolResultBlockParam, - ToolUseBlockParam, -} from '@anthropic-ai/sdk/resources/messages/messages.js'; - -import { - APIConnectionError, - APITimeoutError, - ChatProviderError, - classifyBaseApiError, - normalizeAPIStatusError, - parseRetryAfterMs, - throwIfAbortError, -} from '#/kosong/contract/errors'; -import type { - ContentPart, - Message, - StreamedMessagePart, - ToolCall, -} from '#/kosong/contract/message'; -import { isToolDeclarationOnlyMessage } from '#/kosong/contract/message'; -import type { - ChatProvider, - FinishReason, - GenerateOptions, - ProviderRequestAuth, - ResponseFormat, - StreamedMessage, - ThinkingEffort, - ToolCallIdPolicy, -} from '#/kosong/contract/provider'; -import type { Tool } from '#/kosong/contract/tool'; -import type { TokenUsage } from '#/kosong/contract/usage'; - -import { - BUDGET_THINKING_EFFORTS, - inferAnthropicModelProfile, - matchKnownAnthropicModelProfile, - parseAnthropicModelVersion, - type AnthropicModelProfile, - type AnthropicModelVersion, -} from './anthropic-profile'; -import { mergeConsecutiveUserMessages } from '../merge-user-messages'; -import { mergeRequestHeaders, resolveAuthBackedClient } from '../request-auth'; -import { normalizeToolCallIdsForProvider, sanitizeToolCallId } from '../tool-call-id'; - -function normalizeAnthropicStopReason(raw: string | null | undefined): { - finishReason: FinishReason | null; - rawFinishReason: string | null; -} { - if (raw === null || raw === undefined) { - return { finishReason: null, rawFinishReason: null }; - } - switch (raw) { - case 'end_turn': - case 'stop_sequence': - return { finishReason: 'completed', rawFinishReason: raw }; - case 'max_tokens': - return { finishReason: 'truncated', rawFinishReason: raw }; - case 'tool_use': - return { finishReason: 'tool_calls', rawFinishReason: raw }; - case 'pause_turn': - return { finishReason: 'paused', rawFinishReason: raw }; - case 'refusal': - return { finishReason: 'filtered', rawFinishReason: raw }; - default: - return { finishReason: 'other', rawFinishReason: raw }; - } -} - -export interface AnthropicGenerationKwargs { - max_tokens?: number | undefined; - temperature?: number | undefined; - top_k?: number | undefined; - top_p?: number | undefined; - thinking?: MessageCreateParams['thinking'] | undefined; - output_config?: MessageCreateParams['output_config'] | undefined; - betaFeatures?: string[] | undefined; - contextManagement?: AnthropicContextManagement; -} - -interface AnthropicContextManagement { - edits: Array<{ type: string; keep?: unknown }>; -} - -export interface AnthropicHooks { - withThinking?( - effort: ThinkingEffort, - options: { readonly keep?: string }, - generationKwargs: AnthropicGenerationKwargs, - ): AnthropicGenerationKwargs | undefined; - convertError?: (error: unknown) => ChatProviderError | undefined; -} - -export interface AnthropicOptions { - apiKey?: string | undefined; - baseUrl?: string | undefined; - model: string; - defaultMaxTokens?: number | undefined; - betaFeatures?: string[] | undefined; - defaultHeaders?: Record; - metadata?: Record | undefined; - stream?: boolean | undefined; - adaptiveThinking?: boolean | undefined; - supportEfforts?: readonly string[] | undefined; - betaApi?: boolean | undefined; - thinkingEffort?: ThinkingEffort | undefined; - clientFactory?: (auth: ProviderRequestAuth) => Anthropic; - hooks?: AnthropicHooks | undefined; -} - -const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'; -const CONTEXT_MANAGEMENT_BETA = 'context-management-2025-06-27'; -const CLEAR_THINKING_EDIT = 'clear_thinking_20251015'; -const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { - normalize: (id) => sanitizeToolCallId(id, 64), - maxLength: 64, -}; - -function applyResponseFormat( - kwargs: Record, - format: ResponseFormat | undefined, -): void { - if (format === undefined) return; - if (format.type === 'json_object') { - throw new ChatProviderError( - 'Anthropic provider requires a JSON schema for structured response output.', - ); - } - const outputConfig = - kwargs['output_config'] !== undefined && kwargs['output_config'] !== null - ? { ...(kwargs['output_config'] as Record) } - : {}; - outputConfig['format'] = { - type: 'json_schema', - schema: format.jsonSchema.schema, - }; - kwargs['output_config'] = outputConfig; -} - -const CEILING_BY_FAMILY_VERSION: Readonly> = { - 'fable-5': 128000, - 'mythos-5': 128000, - 'opus-4-8': 128000, - 'opus-4-7': 128000, - 'opus-4-6': 128000, - 'opus-4-5': 64000, - 'opus-4-1': 32000, - 'opus-4-0': 32000, - 'opus-4': 32000, - 'sonnet-5': 128000, - 'sonnet-4-6': 128000, - 'sonnet-4-5': 64000, - 'sonnet-4-0': 64000, - 'sonnet-4': 64000, - 'haiku-4-5': 64000, - 'haiku-4': 64000, - 'opus-3-5': 8192, - 'sonnet-3-5': 8192, - 'sonnet-3-7': 8192, - 'haiku-3-5': 8192, - 'opus-3': 4096, - 'sonnet-3': 4096, - 'haiku-3': 4096, -}; - -const FALLBACK_MAX_TOKENS = 128000; - -function lookupClaudeCeiling(version: AnthropicModelVersion): number | undefined { - const { family, major, minor } = version; - if (minor !== null) { - for (let candidate = minor; candidate >= 0; candidate--) { - const ceiling = CEILING_BY_FAMILY_VERSION[`${family}-${major}-${candidate}`]; - if (ceiling !== undefined) return ceiling; - } - } - return CEILING_BY_FAMILY_VERSION[`${family}-${major}`]; -} - -export function resolveDefaultMaxTokens(model: string, override?: number): number { - const parsed = parseAnthropicModelVersion(model, true); - const ceiling = parsed === null ? undefined : lookupClaudeCeiling(parsed); - if (ceiling === undefined) { - return override ?? FALLBACK_MAX_TOKENS; - } - return override === undefined ? ceiling : Math.min(override, ceiling); -} - -function requiresAdaptiveThinking(efforts: readonly string[]): boolean { - return efforts.some((effort) => effort !== 'low' && effort !== 'medium' && effort !== 'high'); -} - -function resolveThinkingProfile( - model: string, - supportEfforts: readonly string[] | undefined, - adaptiveThinking: boolean | undefined, -): AnthropicModelProfile { - const inferred = inferAnthropicModelProfile(model); - if (adaptiveThinking === false) { - return { - ...inferred, - mode: 'budget', - efforts: supportEfforts ?? BUDGET_THINKING_EFFORTS, - supportsEffortParam: false, - }; - } - - if (adaptiveThinking === true) { - return { - ...inferred, - mode: 'adaptive', - efforts: supportEfforts ?? inferred.efforts, - supportsEffortParam: true, - }; - } - - if (supportEfforts === undefined) { - return inferred; - } - return { - ...inferred, - mode: requiresAdaptiveThinking(supportEfforts) ? 'adaptive' : inferred.mode, - efforts: supportEfforts, - supportsEffortParam: requiresAdaptiveThinking(supportEfforts) || inferred.supportsEffortParam, - }; -} - -function budgetTokensForEffort(effort: ThinkingEffort): number | undefined { - if (effort === 'low') return 1024; - if (effort === 'medium') return 4096; - if (effort === 'on' || effort === 'high') return 32_000; - return undefined; -} - -const CACHE_CONTROL = { type: 'ephemeral' as const }; - -type CacheableBlock = ContentBlockParam & { cache_control?: { type: 'ephemeral' } }; - -function shouldPreserveUnsignedThinking(model: string): boolean { - return ( - parseAnthropicModelVersion(model) === null && - matchKnownAnthropicModelProfile(model) === undefined - ); -} - -const CACHEABLE_TYPES = new Set([ - 'text', - 'image', - 'document', - 'search_result', - 'tool_use', - 'tool_result', - 'server_tool_use', - 'web_search_tool_result', -]); - -function injectCacheControlOnLastBlock(messages: MessageParam[]): void { - const lastMessage = messages.at(-1); - if (lastMessage === undefined) return; - const content = lastMessage.content; - if (!Array.isArray(content) || content.length === 0) return; - const lastBlock = content.at(-1) as CacheableBlock | undefined; - if (lastBlock === undefined) return; - if (CACHEABLE_TYPES.has(lastBlock.type)) { - lastBlock.cache_control = CACHE_CONTROL; - } -} - -function isToolResultOnly(message: MessageParam): boolean { - if (message.role !== 'user') return false; - const content = message.content; - if (!Array.isArray(content) || content.length === 0) return false; - return content.every((block) => block.type === 'tool_result'); -} - -interface AnthropicImageBlock { - type: 'image'; - source: { type: 'base64'; data: string; media_type: string } | { type: 'url'; url: string }; - cache_control?: { type: 'ephemeral' }; -} - -interface AnthropicVideoBlock { - type: 'video'; - source: { type: 'base64'; media_type: string; data: string } | { type: 'url'; url: string }; -} - -const OMITTED_MEDIA_PLACEHOLDER = { - audio_url: '(audio omitted: not supported by this provider)', -} as const; - -const SUPPORTED_B64_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); - -const SUPPORTED_B64_VIDEO_TYPES = new Set([ - 'video/mp4', - 'video/mpeg', - 'video/quicktime', - 'video/webm', - 'video/x-matroska', - 'video/x-msvideo', - 'video/x-flv', - 'video/3gpp', -]); - -function imageUrlPartToAnthropic(url: string): AnthropicImageBlock { - if (url.startsWith('data:')) { - const withoutScheme = url.slice(5); - const parts = withoutScheme.split(';base64,', 2); - if (parts.length !== 2 || parts[0] === undefined || parts[1] === undefined) { - throw new ChatProviderError(`Invalid data URL for image: ${url}`); - } - const mediaType = parts[0]; - const data = parts[1]; - if (!SUPPORTED_B64_MEDIA_TYPES.has(mediaType)) { - throw new ChatProviderError( - `Unsupported media type for base64 image: ${mediaType}, url: ${url}`, - ); - } - return { - type: 'image', - source: { type: 'base64', data, media_type: mediaType }, - }; - } - return { - type: 'image', - source: { type: 'url', url }, - }; -} - -function videoUrlPartToAnthropic(url: string): AnthropicVideoBlock { - if (url.startsWith('data:')) { - const withoutScheme = url.slice(5); - const parts = withoutScheme.split(';base64,', 2); - if (parts.length !== 2 || parts[0] === undefined || parts[1] === undefined) { - throw new ChatProviderError(`Invalid data URL for video: ${url}`); - } - const mediaType = parts[0]; - const data = parts[1]; - if (!SUPPORTED_B64_VIDEO_TYPES.has(mediaType)) { - throw new ChatProviderError( - `Unsupported media type for base64 video: ${mediaType}, url: ${url}`, - ); - } - return { - type: 'video', - source: { type: 'base64', media_type: mediaType, data }, - }; - } - - return { - type: 'video', - source: { type: 'url', url }, - }; -} - -interface AnthropicToolParam extends AnthropicTool { - cache_control?: { type: 'ephemeral' } | null; -} - -function convertTool(tool: Tool): AnthropicToolParam { - return { - name: tool.name, - description: tool.description, - input_schema: tool.parameters as AnthropicTool['input_schema'], - }; -} - -function toolResultToBlock(toolCallId: string, content: ContentPart[]): ToolResultBlockParam { - const blocks: Array = []; - for (const part of content) { - if (part.type === 'text') { - if (part.text) { - blocks.push({ type: 'text', text: part.text }); - } - } else if (part.type === 'image_url') { - blocks.push(imageUrlPartToAnthropic(part.imageUrl.url)); - } else if (part.type === 'video_url') { - blocks.push(videoUrlPartToAnthropic(part.videoUrl.url)); - } else if (part.type === 'audio_url') { - const placeholder = OMITTED_MEDIA_PLACEHOLDER[part.type]; - const last = blocks.at(-1); - if (!(last?.type === 'text' && last.text === placeholder)) { - blocks.push({ type: 'text', text: placeholder }); - } - } - } - return { - type: 'tool_result', - tool_use_id: toolCallId, - content: blocks, - } as ToolResultBlockParam; -} - -function convertMessage(message: Message, model: string): MessageParam { - const role = message.role; - - if (role === 'system') { - const text = message.content - .filter((p) => p.type === 'text') - .map((p) => p.text) - .join('\n'); - return { - role: 'user', - content: [{ type: 'text', text: `${text}` }], - }; - } - - if (role === 'tool') { - if (message.toolCallId === undefined) { - throw new ChatProviderError('Tool message missing `toolCallId`.'); - } - const block = toolResultToBlock(message.toolCallId, message.content); - return { role: 'user', content: [block as ContentBlockParam] }; - } - - const blocks: ContentBlockParam[] = []; - for (const part of message.content) { - if (part.type === 'text') { - blocks.push({ type: 'text', text: part.text } satisfies TextBlockParam); - } else if (part.type === 'image_url') { - blocks.push(imageUrlPartToAnthropic(part.imageUrl.url) as unknown as ContentBlockParam); - } else if (part.type === 'think') { - if (part.encrypted !== undefined) { - blocks.push({ - type: 'thinking', - thinking: part.think, - signature: part.encrypted, - } satisfies ThinkingBlockParam); - } else if (shouldPreserveUnsignedThinking(model)) { - blocks.push({ type: 'thinking', thinking: part.think } as unknown as ThinkingBlockParam); - } - } else if (part.type === 'video_url') { - blocks.push(videoUrlPartToAnthropic(part.videoUrl.url) as unknown as ContentBlockParam); - } else if (part.type === 'audio_url') { - const placeholder = OMITTED_MEDIA_PLACEHOLDER[part.type]; - const last = blocks.at(-1); - if (!(last?.type === 'text' && last.text === placeholder)) { - blocks.push({ type: 'text', text: placeholder } satisfies TextBlockParam); - } - } - } - - if (message.toolCalls.length > 0) { - for (const tc of message.toolCalls) { - let toolInput: Record = {}; - if (tc.arguments) { - try { - const parsed: unknown = JSON.parse(tc.arguments); - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - toolInput = parsed as Record; - } else { - throw new ChatProviderError('Tool call arguments must be a JSON object.'); - } - } catch (error) { - if (error instanceof ChatProviderError) throw error; - throw new ChatProviderError('Tool call arguments must be valid JSON.'); - } - } - blocks.push({ - type: 'tool_use', - id: tc.id, - name: tc.name, - input: toolInput, - } satisfies ToolUseBlockParam); - } - } - - return { role: role, content: blocks }; -} - -function shouldKeepConvertedMessage(message: MessageParam): boolean { - return message.role !== 'assistant' || message.content.length > 0; -} - -export function convertAnthropicError( - error: unknown, - convertErrorHook?: (error: unknown) => ChatProviderError | undefined, -): ChatProviderError { - throwIfAbortError(error); - if (error instanceof ChatProviderError) { - return error; - } - const hooked = convertErrorHook?.(error); - if (hooked !== undefined) { - return hooked; - } - if (error instanceof AnthropicTimeoutError) { - return new APITimeoutError(error.message); - } - if (error instanceof AnthropicConnectionError) { - return new APIConnectionError(error.message); - } - if (error instanceof AnthropicAPIError && typeof error.status === 'number') { - const reqId = error.requestID ?? null; - return normalizeAPIStatusError( - error.status, - error.message, - reqId, - parseRetryAfterMs(error.headers), - ); - } - if (error instanceof AnthropicError) { - return new ChatProviderError(`Anthropic error: ${error.message}`); - } - if (error instanceof Error) { - return classifyBaseApiError(error.message); - } - return new ChatProviderError(`Error: ${String(error)}`); -} - -class AnthropicStreamedMessage implements StreamedMessage { - private _id: string | null = null; - private _usage: TokenUsage = { - inputOther: 0, - output: 0, - inputCacheRead: 0, - inputCacheCreation: 0, - }; - private _finishReason: FinishReason | null = null; - private _rawFinishReason: string | null = null; - private readonly _iter: AsyncGenerator; - - constructor( - response: unknown, - isStream: boolean, - private readonly _convertErrorHook?: - | ((error: unknown) => ChatProviderError | undefined) - | undefined, - ) { - if (isStream) { - this._iter = this._convertStreamResponse(response as AsyncIterable); - } else { - this._iter = this._convertNonStreamResponse( - response as { - id: string; - stop_reason?: string | null; - usage: { - input_tokens: number; - output_tokens: number; - cache_read_input_tokens?: number; - cache_creation_input_tokens?: number; - }; - content: Array<{ - type: string; - text?: string; - thinking?: string; - signature?: string; - data?: string; - id?: string; - name?: string; - input?: unknown; - }>; - }, - ); - } - } - - get id(): string | null { - return this._id; - } - - get usage(): TokenUsage | null { - return this._usage; - } - - get finishReason(): FinishReason | null { - return this._finishReason; - } - - get rawFinishReason(): string | null { - return this._rawFinishReason; - } - - async *[Symbol.asyncIterator](): AsyncIterator { - yield* this._iter; - } - - private _captureStopReason(raw: string | null | undefined): void { - const normalized = normalizeAnthropicStopReason(raw); - this._finishReason = normalized.finishReason; - this._rawFinishReason = normalized.rawFinishReason; - } - - private _extractUsage(usage: { - input_tokens?: number; - output_tokens?: number; - cache_read_input_tokens?: number; - cache_creation_input_tokens?: number; - }): void { - this._usage = { - inputOther: usage.input_tokens ?? 0, - output: usage.output_tokens ?? 0, - inputCacheRead: usage.cache_read_input_tokens ?? 0, - inputCacheCreation: usage.cache_creation_input_tokens ?? 0, - }; - } - - private async *_convertNonStreamResponse(response: { - id: string; - stop_reason?: string | null; - usage: { - input_tokens: number; - output_tokens: number; - cache_read_input_tokens?: number; - cache_creation_input_tokens?: number; - }; - content: Array<{ - type: string; - text?: string; - thinking?: string; - signature?: string; - data?: string; - id?: string; - name?: string; - input?: unknown; - }>; - }): AsyncGenerator { - this._id = response.id; - this._extractUsage(response.usage); - this._captureStopReason(response.stop_reason); - - for (const block of response.content) { - switch (block.type) { - case 'text': - if (block.text !== undefined) { - yield { type: 'text', text: block.text }; - } - break; - case 'thinking': - yield block.signature !== undefined - ? { type: 'think' as const, think: block.thinking ?? '', encrypted: block.signature } - : { type: 'think' as const, think: block.thinking ?? '' }; - break; - case 'redacted_thinking': - yield block.data !== undefined - ? { type: 'think' as const, think: '', encrypted: block.data } - : { type: 'think' as const, think: '' }; - break; - case 'tool_use': - yield { - type: 'function', - id: block.id ?? crypto.randomUUID(), - name: block.name ?? '', - arguments: block.input !== undefined ? JSON.stringify(block.input) : null, - } satisfies ToolCall; - break; - } - } - } - - private async *_convertStreamResponse( - response: AsyncIterable, - ): AsyncGenerator { - const toolUseBlockIndexes = new Set(); - - try { - for await (const event of response) { - const evt = event as unknown as Record; - const eventType = evt['type'] as string; - - if (eventType === 'message_start') { - const startEvt = evt as unknown as RawMessageStartEvent; - this._id = startEvt.message.id; - this._extractUsage( - startEvt.message.usage as { - input_tokens?: number; - output_tokens?: number; - cache_read_input_tokens?: number; - cache_creation_input_tokens?: number; - }, - ); - } else if (eventType === 'content_block_start') { - const blockEvt = evt as unknown as RawContentBlockStartEvent; - const block = blockEvt.content_block; - const blockIndex = blockEvt.index; - switch (block.type) { - case 'text': - yield { type: 'text', text: block.text }; - break; - case 'thinking': - yield { type: 'think', think: block.thinking ?? '' }; - break; - case 'redacted_thinking': - yield { - type: 'think', - think: '', - encrypted: (block as unknown as { data: string }).data, - }; - break; - case 'tool_use': - toolUseBlockIndexes.add(blockIndex); - yield { - type: 'function', - id: block.id, - name: block.name, - arguments: '', - _streamIndex: blockIndex, - } satisfies ToolCall; - break; - } - } else if (eventType === 'content_block_delta') { - const deltaEvt = evt as unknown as RawContentBlockDeltaEvent; - const delta = deltaEvt.delta; - const blockIndex = deltaEvt.index; - switch (delta.type) { - case 'text_delta': - yield { type: 'text', text: delta.text }; - break; - case 'thinking_delta': - yield { type: 'think', think: delta.thinking ?? '' }; - break; - case 'input_json_delta': - yield { - type: 'tool_call_part', - argumentsPart: delta.partial_json, - index: blockIndex, - }; - break; - case 'signature_delta': - yield { - type: 'think', - think: '', - encrypted: delta.signature, - }; - break; - } - } else if (eventType === 'content_block_stop') { - } else if (eventType === 'message_delta') { - const deltaUsage = (evt as { usage?: Record }).usage; - if (deltaUsage !== undefined) { - if (typeof deltaUsage['output_tokens'] === 'number') { - this._usage.output = deltaUsage['output_tokens']; - } - if (typeof deltaUsage['cache_read_input_tokens'] === 'number') { - this._usage.inputCacheRead = deltaUsage['cache_read_input_tokens']; - } - if (typeof deltaUsage['cache_creation_input_tokens'] === 'number') { - this._usage.inputCacheCreation = deltaUsage['cache_creation_input_tokens']; - } - if (typeof deltaUsage['input_tokens'] === 'number') { - this._usage.inputOther = deltaUsage['input_tokens']; - } - } - const messageDeltaPayload = (evt as { delta?: Record }).delta; - if (messageDeltaPayload !== undefined && 'stop_reason' in messageDeltaPayload) { - this._captureStopReason( - messageDeltaPayload['stop_reason'] as string | null | undefined, - ); - } - } - } - } catch (error: unknown) { - throw convertAnthropicError(error, this._convertErrorHook); - } - } -} - -export class AnthropicChatProvider implements ChatProvider { - readonly name: string = 'anthropic'; - - private readonly _model: string; - private readonly _stream: boolean; - private readonly _client: Anthropic | undefined; - private readonly _generationKwargs: AnthropicGenerationKwargs; - private readonly _metadata: Record | undefined; - private readonly _apiKey: string | undefined; - private readonly _baseUrl: string | undefined; - private readonly _defaultHeaders: Record | undefined; - private readonly _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined; - private readonly _adaptiveThinking: boolean | undefined; - private readonly _supportEfforts: readonly string[] | undefined; - private readonly _betaApi: boolean; - private readonly _thinkingEffort: ThinkingEffort | undefined; - private readonly _explicitMaxTokens: boolean; - private readonly _hooks: AnthropicHooks | undefined; - - constructor(options: AnthropicOptions) { - this._model = options.model; - this._stream = options.stream ?? true; - this._metadata = options.metadata; - this._adaptiveThinking = options.adaptiveThinking; - this._supportEfforts = options.supportEfforts; - this._betaApi = options.betaApi ?? false; - this._thinkingEffort = options.thinkingEffort; - this._hooks = options.hooks; - this._apiKey = - options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey; - this._baseUrl = options.baseUrl; - this._defaultHeaders = options.defaultHeaders; - this._clientFactory = options.clientFactory; - this._client = this._apiKey === undefined ? undefined : this._buildClient(this._apiKey); - this._explicitMaxTokens = options.defaultMaxTokens !== undefined; - this._generationKwargs = { - max_tokens: options.defaultMaxTokens ?? resolveDefaultMaxTokens(options.model), - betaFeatures: options.betaFeatures ?? [INTERLEAVED_THINKING_BETA], - }; - } - - get modelName(): string { - return this._model; - } - - get thinkingEffort(): ThinkingEffort | null { - return this._thinkingEffort ?? null; - } - - get maxCompletionTokens(): number | undefined { - return this._generationKwargs.max_tokens; - } - - async generate( - systemPrompt: string, - tools: Tool[], - history: Message[], - options?: GenerateOptions, - ): Promise { - const system: TextBlockParam[] | undefined = systemPrompt - ? [ - { - type: 'text', - text: systemPrompt, - cache_control: CACHE_CONTROL, - } as TextBlockParam, - ] - : undefined; - - const messages = mergeConsecutiveUserMessages( - normalizeToolCallIdsForProvider( - history.filter((msg) => !isToolDeclarationOnlyMessage(msg)), - ANTHROPIC_TOOL_CALL_ID_POLICY, - ) - .map((msg) => convertMessage(msg, this._model)) - .filter(shouldKeepConvertedMessage), - { - isUser: (message) => message.role === 'user', - isToolResultOnly, - merge: (last, next) => ({ - ...last, - content: [ - ...(last.content as ContentBlockParam[]), - ...(next.content as ContentBlockParam[]), - ], - }), - }, - ); - - injectCacheControlOnLastBlock(messages); - - let kwargs: AnthropicGenerationKwargs = { ...this._generationKwargs }; - let useBetaApi = this._betaApi; - - let metadata = this._metadata; - if (options?.cacheKey !== undefined) { - metadata = { ...metadata, user_id: options.cacheKey }; - } - - if (options?.sampling?.temperature !== undefined) { - kwargs = { ...kwargs, temperature: options.sampling.temperature }; - } - if (options?.sampling?.topP !== undefined) { - kwargs = { ...kwargs, top_p: options.sampling.topP }; - } - - const thinking = - options?.thinking ?? - (this._thinkingEffort !== undefined ? { effort: this._thinkingEffort } : undefined); - if (thinking !== undefined) { - const hooked = this._hooks?.withThinking?.( - thinking.effort, - { keep: thinking.keep }, - { ...kwargs }, - ); - if (hooked !== undefined) { - kwargs = { ...kwargs, ...hooked }; - } else { - kwargs = { ...kwargs, ...this._encodeThinking(thinking.effort, kwargs) }; - } - if (thinking.keep !== undefined) { - kwargs = { ...kwargs, ...applyThinkingKeep(kwargs, thinking.keep) }; - useBetaApi = true; - } - } - - if (options?.maxCompletionTokens !== undefined) { - let cap = options.maxCompletionTokens; - if ( - options.usedContextTokens !== undefined && - options.maxContextTokens !== undefined && - options.maxContextTokens > 0 - ) { - cap = Math.min(cap, options.maxContextTokens - options.usedContextTokens); - } - cap = Math.max(1, cap); - const requestedCap = resolveDefaultMaxTokens(this._model, cap); - const existingCap = kwargs.max_tokens; - kwargs = { - ...kwargs, - max_tokens: - existingCap === undefined || this._explicitMaxTokens - ? (existingCap ?? requestedCap) - : Math.min(existingCap, requestedCap), - }; - } - - const requestKwargs: Record = {}; - if (kwargs.max_tokens !== undefined) { - requestKwargs['max_tokens'] = kwargs.max_tokens; - } - if (kwargs.temperature !== undefined) { - requestKwargs['temperature'] = kwargs.temperature; - } - if (kwargs.top_k !== undefined) { - requestKwargs['top_k'] = kwargs.top_k; - } - if (kwargs.top_p !== undefined) { - requestKwargs['top_p'] = kwargs.top_p; - } - if (kwargs.thinking !== undefined) { - requestKwargs['thinking'] = kwargs.thinking; - } - if (kwargs.output_config !== undefined) { - requestKwargs['output_config'] = kwargs.output_config; - } - if (kwargs.contextManagement !== undefined) { - requestKwargs['context_management'] = kwargs.contextManagement; - } - applyResponseFormat(requestKwargs, options?.responseFormat); - - const betas = kwargs.betaFeatures ?? []; - const extraHeaders: Record = {}; - if (!useBetaApi && betas.length > 0) { - extraHeaders['anthropic-beta'] = betas.join(','); - } - - const anthropicTools: AnthropicToolParam[] = tools.map((t) => convertTool(t)); - if (anthropicTools.length > 0) { - const lastTool = anthropicTools.at(-1); - if (lastTool !== undefined) { - lastTool.cache_control = CACHE_CONTROL; - } - } - - const createParams: Record = { - model: this._model, - messages, - ...requestKwargs, - }; - - if (system !== undefined) { - createParams['system'] = system; - } - - if (anthropicTools.length > 0) { - createParams['tools'] = anthropicTools; - } - - if (metadata !== undefined) { - createParams['metadata'] = metadata; - } - - if (useBetaApi && betas.length > 0) { - createParams['betas'] = betas; - } - - const requestOptions: Record = {}; - const headers = mergeRequestHeaders( - mergeRequestHeaders(extraHeaders, options?.extraHeaders), - options?.auth?.headers, - ); - if (headers !== undefined) { - requestOptions['headers'] = headers; - } - if (options?.signal) { - requestOptions['signal'] = options.signal; - } - const finalRequestOptions = Object.keys(requestOptions).length > 0 ? requestOptions : undefined; - const client = this._createClient(options?.auth); - options?.onRequestSent?.(); - - if (this._stream) { - try { - const stream = useBetaApi - ? await client.beta.messages.create( - { ...createParams, stream: true } as unknown as MessageCreateParamsStreaming, - finalRequestOptions, - ) - : await client.messages.create( - { ...createParams, stream: true } as unknown as MessageCreateParamsStreaming, - finalRequestOptions, - ); - return new AnthropicStreamedMessage(stream, true, this._hooks?.convertError); - } catch (error: unknown) { - throw convertAnthropicError(error, this._hooks?.convertError); - } - } - - try { - const response = useBetaApi - ? await client.beta.messages.create( - { ...createParams, stream: false } as unknown as MessageCreateParams, - finalRequestOptions, - ) - : await client.messages.create( - { ...createParams, stream: false } as unknown as MessageCreateParams, - finalRequestOptions, - ); - return new AnthropicStreamedMessage(response, false, this._hooks?.convertError); - } catch (error: unknown) { - throw convertAnthropicError(error, this._hooks?.convertError); - } - } - - private _encodeThinking( - effort: ThinkingEffort, - kwargs: AnthropicGenerationKwargs, - ): AnthropicGenerationKwargs { - const profile = resolveThinkingProfile( - this._model, - this._supportEfforts, - this._adaptiveThinking, - ); - - let newBetas = [...(kwargs.betaFeatures ?? [])]; - if (profile.mode === 'adaptive') { - newBetas = newBetas.filter((b) => b !== INTERLEAVED_THINKING_BETA); - } - - if (effort === 'off') { - return { - thinking: { type: 'disabled' }, - output_config: undefined, - betaFeatures: newBetas, - }; - } - - if (profile.mode === 'adaptive') { - return { - thinking: { type: 'adaptive', display: 'summarized' }, - output_config: - effort === 'on' ? undefined : ({ effort } as MessageCreateParams['output_config']), - betaFeatures: newBetas, - }; - } - - const budgetTokens = budgetTokensForEffort(effort); - const patch: AnthropicGenerationKwargs = { - thinking: - budgetTokens === undefined - ? ({ type: 'enabled' } as MessageCreateParams['thinking']) - : { type: 'enabled', budget_tokens: budgetTokens }, - betaFeatures: newBetas, - }; - if ((profile.supportsEffortParam || budgetTokens === undefined) && effort !== 'on') { - patch.output_config = { effort } as MessageCreateParams['output_config']; - } else { - patch.output_config = undefined; - } - return patch; - } - - private _createClient(auth: ProviderRequestAuth | undefined): Anthropic { - return resolveAuthBackedClient( - { cachedClient: this._client, clientFactory: this._clientFactory }, - auth, - (a) => this._buildClient(this._requireApiKey(a)), - ); - } - - private _requireApiKey(auth: ProviderRequestAuth | undefined): string { - const apiKey = auth?.apiKey ?? this._apiKey; - if (apiKey === undefined || apiKey.length === 0) { - throw new ChatProviderError( - 'AnthropicChatProvider: apiKey is required. Provide it via constructor options, options.auth.apiKey on each request, or an OAuth login. The Anthropic adapter does not read shell API-key environment variables.', - ); - } - return apiKey; - } - - private _anthropicCustomHeaderEnvNames(): string[] { - const customHeaders = process.env['ANTHROPIC_CUSTOM_HEADERS']; - if (customHeaders === undefined || customHeaders.length === 0) return []; - - const names: string[] = []; - for (const line of customHeaders.split('\n')) { - const colonIndex = line.indexOf(':'); - if (colonIndex < 0) continue; - - const name = line.slice(0, colonIndex).trim().toLowerCase(); - if (name.length > 0) names.push(name); - } - return names; - } - - private _buildDefaultHeaders(apiKey: string): Record { - const defaultHeaders: Record = { authorization: null }; - for (const name of this._anthropicCustomHeaderEnvNames()) { - defaultHeaders[name] = null; - } - for (const [name, value] of Object.entries(this._defaultHeaders ?? {})) { - defaultHeaders[name.toLowerCase()] = value; - } - defaultHeaders['x-api-key'] = apiKey; - return defaultHeaders; - } - - private _buildClient(apiKey: string): Anthropic { - return new Anthropic({ - apiKey, - authToken: null, - baseURL: this._baseUrl ?? null, - defaultHeaders: this._buildDefaultHeaders(apiKey), - maxRetries: 0, - }); - } -} - -function applyThinkingKeep( - kwargs: AnthropicGenerationKwargs, - keep: string, -): AnthropicGenerationKwargs { - const current = kwargs.betaFeatures ?? []; - const betaFeatures = current.includes(CONTEXT_MANAGEMENT_BETA) - ? current - : [...current, CONTEXT_MANAGEMENT_BETA]; - const existingEdits = kwargs.contextManagement?.edits ?? []; - const edits = [ - { type: CLEAR_THINKING_EDIT, keep }, - ...existingEdits.filter((edit) => edit.type !== CLEAR_THINKING_EDIT), - ]; - return { - contextManagement: { edits }, - betaFeatures, - }; -} - -const CLAUDE_VISION_TOOL_PREFIXES = ['claude-3-', 'claude-3.5-', 'claude-3.7-'] as const; - -const CLAUDE_THINKING_VISION_TOOL_PREFIXES = [ - 'claude-opus-4', - 'claude-sonnet-4', - 'claude-haiku-4', - 'claude-fable', -] as const; - -const ANTHROPIC_VISION_TOOL_CAPABILITY = Object.freeze({ - image_in: true, - video_in: false, - audio_in: false, - thinking: false, - tool_use: true, - max_context_tokens: 0, -}); - -const ANTHROPIC_THINKING_VISION_TOOL_CAPABILITY = Object.freeze({ - image_in: true, - video_in: false, - audio_in: false, - thinking: true, - tool_use: true, - max_context_tokens: 0, -}); - -export function getAnthropicModelCapability(modelName: string) { - const normalized = modelName.toLowerCase(); - if (CLAUDE_VISION_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return ANTHROPIC_VISION_TOOL_CAPABILITY; - } - if (CLAUDE_THINKING_VISION_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return ANTHROPIC_THINKING_VISION_TOOL_CAPABILITY; - } - return undefined; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropicHooks.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropicHooks.ts deleted file mode 100644 index 1f266e102..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropicHooks.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { traitConvertError, type ResolvedTrait } from '#/kosong/protocol/protocolTrait'; - -import type { AnthropicHooks } from './anthropic'; - -export function composeAnthropicHooks( - traits: readonly ResolvedTrait[], -): AnthropicHooks | undefined { - const hooks: AnthropicHooks = {}; - - const thinkingTraits = traits.filter(({ trait }) => trait.withThinking !== undefined); - if (thinkingTraits.length > 0) { - const { trait, context } = thinkingTraits.at(-1)!; - hooks.withThinking = (effort, options, kwargs) => - trait.withThinking!(effort, options, { ...kwargs }, context); - } - - const convertError = traitConvertError(traits); - if (convertError !== undefined) { - hooks.convertError = convertError; - } - - return Object.keys(hooks).length > 0 ? hooks : undefined; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/index.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/index.ts deleted file mode 100644 index 7c82d9fe9..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './anthropic.contrib'; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.contrib.ts b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.contrib.ts deleted file mode 100644 index b9d262469..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.contrib.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { registerProtocolBase } from '#/kosong/protocol/protocolBase'; -import { traitDefaultHeaders } from '#/kosong/protocol/protocolTrait'; - -import { getGoogleGenAIModelCapability, GoogleGenAIChatProvider } from './google-genai'; -import { compactObject, firstProcessEnv, traitEndpoint, traitProvides } from '../openai/openaiHooks'; - -registerProtocolBase({ - id: 'google-genai', - capability: getGoogleGenAIModelCapability, - createChatProvider({ config, traits }) { - const endpoint = traitEndpoint(traits); - return new GoogleGenAIChatProvider({ - ...(traitProvides(traits) as Partial< - ConstructorParameters[0] - >), - model: config.modelName, - ...compactObject({ - apiKey: - config.apiKey ?? - firstProcessEnv(endpoint?.apiKeyEnv) ?? - (endpoint === undefined ? undefined : ''), - baseUrl: - config.baseUrl ?? firstProcessEnv(endpoint?.baseUrlEnv) ?? endpoint?.defaultBaseUrl, - defaultHeaders: traitDefaultHeaders(traits), - vertexai: config.providerOptions?.vertexai, - project: config.providerOptions?.project, - location: config.providerOptions?.location, - }), - }); - }, -}); diff --git a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts deleted file mode 100644 index fcd677d18..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts +++ /dev/null @@ -1,932 +0,0 @@ -import { ApiError as GoogleApiError, GoogleGenAI as GenAIClient } from '@google/genai'; - -import { - APIConnectionError, - APITimeoutError, - ChatProviderError, - normalizeAPIStatusError, -} from '#/kosong/contract/errors'; -import type { Message, StreamedMessagePart, ThinkPart, ToolCall } from '#/kosong/contract/message'; -import { isToolDeclarationOnlyMessage } from '#/kosong/contract/message'; -import type { - ChatProvider, - FinishReason, - GenerateOptions, - ProviderRequestAuth, - ResponseFormat, - StreamedMessage, - ThinkingEffort, -} from '#/kosong/contract/provider'; -import type { Tool } from '#/kosong/contract/tool'; -import type { TokenUsage } from '#/kosong/contract/usage'; - -import { mergeConsecutiveUserMessages } from '../merge-user-messages'; -import { - mergeRequestHeaders, - requireProviderApiKey, - resolveAuthBackedClient, -} from '../request-auth'; - -function normalizeGoogleGenAIFinishReason(raw: unknown): { - finishReason: FinishReason | null; - rawFinishReason: string | null; -} { - if (raw === null || raw === undefined) { - return { finishReason: null, rawFinishReason: null }; - } - let rawString: string; - if (typeof raw === 'string') { - rawString = raw.toUpperCase(); - } else if (typeof raw === 'number' || typeof raw === 'bigint' || typeof raw === 'boolean') { - rawString = String(raw).toUpperCase(); - } else { - return { finishReason: null, rawFinishReason: null }; - } - if (rawString === 'FINISH_REASON_UNSPECIFIED' || rawString === '') { - return { finishReason: null, rawFinishReason: null }; - } - switch (rawString) { - case 'STOP': - return { finishReason: 'completed', rawFinishReason: rawString }; - case 'MAX_TOKENS': - return { finishReason: 'truncated', rawFinishReason: rawString }; - case 'SAFETY': - case 'RECITATION': - case 'BLOCKLIST': - case 'PROHIBITED_CONTENT': - case 'SPII': - case 'IMAGE_SAFETY': - return { finishReason: 'filtered', rawFinishReason: rawString }; - case 'MALFORMED_FUNCTION_CALL': - case 'OTHER': - case 'LANGUAGE': - return { finishReason: 'other', rawFinishReason: rawString }; - default: - return { finishReason: 'other', rawFinishReason: rawString }; - } -} - -export interface GoogleGenAIOptions { - apiKey?: string | undefined; - model: string; - baseUrl?: string; - vertexai?: boolean | undefined; - project?: string | undefined; - location?: string | undefined; - stream?: boolean | undefined; - thinkingEffort?: ThinkingEffort | undefined; - defaultHeaders?: Record; - clientFactory?: (auth: ProviderRequestAuth) => GenAIClient; -} - -export interface GoogleGenAIGenerationKwargs { - maxOutputTokens?: number; - temperature?: number; - topK?: number; - topP?: number; - thinkingConfig?: ThinkingConfig; - [key: string]: unknown; -} - -interface ThinkingConfig { - includeThoughts?: boolean; - thinkingBudget?: number; - thinkingLevel?: string; -} - -interface GoogleFunctionDeclaration { - name: string; - description: string; - parametersJsonSchema: Record; -} - -interface GoogleTool { - functionDeclarations: GoogleFunctionDeclaration[]; -} - -function toolToGoogleGenAI(tool: Tool): GoogleTool { - return { - functionDeclarations: [ - { - name: tool.name, - description: tool.description, - parametersJsonSchema: tool.parameters, - }, - ], - }; -} - -function applyResponseFormat( - config: Record, - format: ResponseFormat | undefined, -): void { - if (format === undefined) return; - config['responseMimeType'] = 'application/json'; - delete config['responseSchema']; - delete config['responseJsonSchema']; - if (format.type === 'json_schema') { - config['responseJsonSchema'] = format.jsonSchema.schema; - } -} - -interface GoogleContent { - role: string; - parts: GooglePart[]; -} - -interface GooglePart { - text?: string; - thought?: boolean; - functionCall?: { name: string; args: Record }; - functionResponse?: { - name: string; - response: Record; - parts: unknown[]; - }; - thoughtSignature?: string; - [key: string]: unknown; -} - -function toolCallIdToName(toolCallId: string, toolNameById: Map): string { - const name = toolNameById.get(toolCallId); - if (name !== undefined) return name; - const withoutEntropy = toolCallId.replace(/_[0-9a-f]{8}$/, ''); - const match = /^(.+)_[^_]+$/.exec(withoutEntropy); - return match?.[1] ?? withoutEntropy; -} - -function convertMediaUrl( - url: string, - fallbackMimeType: string, -): - | { inlineData: { mimeType: string; data: string } } - | { fileData: { fileUri: string; mimeType: string } } { - if (url.startsWith('data:')) { - const commaIndex = url.indexOf(','); - if (commaIndex === -1) { - return { fileData: { fileUri: url, mimeType: fallbackMimeType } }; - } - const meta = url.slice(0, commaIndex); - const data = url.slice(commaIndex + 1); - const colonIndex = meta.indexOf(':'); - const semiIndex = meta.indexOf(';'); - const mimeType = - colonIndex !== -1 && semiIndex !== -1 - ? meta.slice(colonIndex + 1, semiIndex) - : fallbackMimeType; - return { inlineData: { mimeType, data } }; - } - let mimeType = fallbackMimeType; - try { - const pathname = new URL(url).pathname.toLowerCase(); - if (pathname.endsWith('.png')) mimeType = 'image/png'; - else if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) mimeType = 'image/jpeg'; - else if (pathname.endsWith('.gif')) mimeType = 'image/gif'; - else if (pathname.endsWith('.webp')) mimeType = 'image/webp'; - else if (pathname.endsWith('.mp3') || pathname.endsWith('.mpeg')) mimeType = 'audio/mpeg'; - else if (pathname.endsWith('.wav')) mimeType = 'audio/wav'; - else if (pathname.endsWith('.ogg')) mimeType = 'audio/ogg'; - } catch {} - return { fileData: { fileUri: url, mimeType } }; -} - -function createAbortError(): DOMException { - return new DOMException('The operation was aborted.', 'AbortError'); -} - -async function abortPromise(signal: AbortSignal | undefined): Promise { - if (signal === undefined) { - return new Promise(() => {}); - } - if (signal.aborted) { - throw createAbortError(); - } - return new Promise((_, reject) => { - signal.addEventListener( - 'abort', - () => { - reject(createAbortError()); - }, - { once: true }, - ); - }); -} - -function messageToGoogleGenAI(message: Message): GoogleContent { - if (message.role === 'tool') { - throw new ChatProviderError( - 'Tool messages must be converted via messagesToGoogleGenAIContents.', - ); - } - - const role = message.role === 'assistant' ? 'model' : message.role; - const parts: GooglePart[] = []; - - for (const part of message.content) { - switch (part.type) { - case 'text': - parts.push({ text: part.text }); - break; - case 'think': { - const thoughtPart: GooglePart = { text: part.think, thought: true }; - if (part.encrypted !== undefined && part.encrypted.length > 0) { - thoughtPart.thoughtSignature = part.encrypted; - } - parts.push(thoughtPart); - break; - } - case 'image_url': - parts.push(convertMediaUrl(part.imageUrl.url, 'image/jpeg')); - break; - case 'audio_url': - parts.push(convertMediaUrl(part.audioUrl.url, 'audio/mpeg')); - break; - case 'video_url': - parts.push(convertMediaUrl(part.videoUrl.url, 'video/mp4')); - break; - } - } - - for (const toolCall of message.toolCalls) { - let args: Record = {}; - if (toolCall.arguments) { - try { - const parsed: unknown = JSON.parse(toolCall.arguments); - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - args = parsed as Record; - } else { - throw new ChatProviderError('Tool call arguments must be a JSON object.'); - } - } catch (error) { - if (error instanceof ChatProviderError) throw error; - throw new ChatProviderError('Tool call arguments must be valid JSON.'); - } - } - - const functionCallPart: GooglePart = { - functionCall: { - name: toolCall.name, - args, - }, - }; - - if (toolCall.extras && 'thought_signature_b64' in toolCall.extras) { - functionCallPart['thoughtSignature'] = toolCall.extras['thought_signature_b64'] as string; - } - - parts.push(functionCallPart); - } - - return { role, parts }; -} - -function toolMessageToFunctionResponseParts( - message: Message, - toolNameById: Map, -): GooglePart[] { - if (message.role !== 'tool') { - throw new ChatProviderError('Expected a tool message.'); - } - if (message.toolCallId === undefined) { - throw new ChatProviderError('Tool response is missing `toolCallId`.'); - } - - let textOutput = ''; - const mediaParts: GooglePart[] = []; - for (const part of message.content) { - switch (part.type) { - case 'text': - if (part.text) textOutput += part.text; - break; - case 'image_url': - mediaParts.push(convertMediaUrl(part.imageUrl.url, 'image/jpeg')); - break; - case 'audio_url': - mediaParts.push(convertMediaUrl(part.audioUrl.url, 'audio/mpeg')); - break; - case 'video_url': - mediaParts.push(convertMediaUrl(part.videoUrl.url, 'video/mp4')); - break; - case 'think': - break; - } - } - - const functionResponsePart: GooglePart = { - functionResponse: { - name: toolCallIdToName(message.toolCallId, toolNameById), - response: { output: textOutput }, - parts: [], - }, - }; - - return [functionResponsePart, ...mediaParts]; -} - -export function messagesToGoogleGenAIContents(messages: Message[]): GoogleContent[] { - const contents: GoogleContent[] = []; - const toolNameById = new Map(); - - let i = 0; - while (i < messages.length) { - const message = messages[i]; - if (message === undefined) break; - - if (isToolDeclarationOnlyMessage(message)) { - i += 1; - continue; - } - - if (message.role === 'system') { - const text = message.content - .filter((p): p is { type: 'text'; text: string } => p.type === 'text') - .map((p) => p.text) - .join('\n'); - if (text.length > 0) { - contents.push({ - role: 'user', - parts: [{ text: `${text}` }], - }); - } - i += 1; - continue; - } - - if (message.role === 'assistant' && message.toolCalls.length > 0) { - contents.push(messageToGoogleGenAI(message)); - const expectedToolCallIds: string[] = []; - for (const toolCall of message.toolCalls) { - toolNameById.set(toolCall.id, toolCall.name); - expectedToolCallIds.push(toolCall.id); - } - - let j = i + 1; - const toolMessages: Message[] = []; - while (j < messages.length) { - const toolMsg = messages[j]; - if (toolMsg === undefined || toolMsg.role !== 'tool') break; - toolMessages.push(toolMsg); - j += 1; - } - - if (toolMessages.length > 0) { - const toolMsgById = new Map(); - const seenToolCallIds = new Set(); - for (const toolMsg of toolMessages) { - if (toolMsg.toolCallId === undefined) { - throw new ChatProviderError('Tool response is missing `toolCallId`.'); - } - if (seenToolCallIds.has(toolMsg.toolCallId)) { - throw new ChatProviderError(`Duplicate tool response for id: ${toolMsg.toolCallId}`); - } - seenToolCallIds.add(toolMsg.toolCallId); - toolMsgById.set(toolMsg.toolCallId, toolMsg); - } - - const sortedToolMessages: Message[] = []; - for (const expectedId of expectedToolCallIds) { - const msg = toolMsgById.get(expectedId); - if (msg === undefined) { - throw new ChatProviderError(`Missing tool responses for ids: ${expectedId}`); - } - sortedToolMessages.push(msg); - toolMsgById.delete(expectedId); - } - if (toolMsgById.size > 0) { - throw new ChatProviderError( - `Unexpected tool responses for ids: ${JSON.stringify([...toolMsgById.keys()])}`, - ); - } - - const parts: GooglePart[] = []; - for (const toolMsg of sortedToolMessages) { - parts.push(...toolMessageToFunctionResponseParts(toolMsg, toolNameById)); - } - contents.push({ role: 'user', parts }); - i = j; - continue; - } - - i += 1; - continue; - } - - if (message.role === 'tool') { - const parts: GooglePart[] = toolMessageToFunctionResponseParts(message, toolNameById); - contents.push({ role: 'user', parts }); - i += 1; - continue; - } - - contents.push(messageToGoogleGenAI(message)); - i += 1; - } - - return mergeConsecutiveUserMessages(contents, { - isUser: (content) => content.role === 'user', - isToolResultOnly: (content) => - content.parts.length > 0 && - content.parts.every((part) => part.functionResponse !== undefined), - merge: (last, next) => { - const lastStartsWithFunctionResponse = - last.parts[0]?.functionResponse !== undefined; - const nextHasFunctionResponse = next.parts.some( - (part) => part.functionResponse !== undefined, - ); - if (lastStartsWithFunctionResponse && !nextHasFunctionResponse) { - return { ...next, parts: [...next.parts, ...last.parts] }; - } - return { ...last, parts: [...last.parts, ...next.parts] }; - }, - }); -} - -export class GoogleGenAIStreamedMessage implements StreamedMessage { - private _id: string | null = null; - private _usage: TokenUsage | null = null; - private _finishReason: FinishReason | null = null; - private _rawFinishReason: string | null = null; - private readonly _iter: AsyncGenerator; - - constructor( - response: AsyncIterable> | Record, - isStream: boolean, - signal?: AbortSignal, - ) { - if (isStream) { - this._iter = this._convertStreamResponse( - response as AsyncIterable>, - signal, - ); - } else { - this._iter = this._convertNonStreamResponse(response as Record, signal); - } - } - - get id(): string | null { - return this._id; - } - - get usage(): TokenUsage | null { - return this._usage; - } - - get finishReason(): FinishReason | null { - return this._finishReason; - } - - get rawFinishReason(): string | null { - return this._rawFinishReason; - } - - async *[Symbol.asyncIterator](): AsyncIterator { - yield* this._iter; - } - - private _captureFinishReason(response: Record): void { - const candidates = response['candidates'] as unknown[] | undefined; - if (!candidates || candidates.length === 0) { - return; - } - const first = candidates[0] as Record | undefined; - if (first === undefined) { - return; - } - const raw = first['finishReason'] ?? first['finish_reason']; - if (raw === undefined) { - return; - } - const normalized = normalizeGoogleGenAIFinishReason(raw); - if (normalized.finishReason !== null || normalized.rawFinishReason !== null) { - this._finishReason = normalized.finishReason; - this._rawFinishReason = normalized.rawFinishReason; - } - } - - private _extractChunkParts(response: Record): StreamedMessagePart[] { - const parts: StreamedMessagePart[] = []; - - const candidates = response['candidates'] as unknown[] | undefined; - for (const candidate of candidates ?? []) { - const cand = candidate as Record; - const content = cand['content'] as Record | undefined; - const contentParts = content?.['parts'] as unknown[] | undefined; - if (!contentParts) continue; - - for (const part of contentParts) { - const p = part as Record; - if (p['thought'] === true && typeof p['text'] === 'string') { - const thoughtSignature = p['thoughtSignature'] ?? p['thought_signature']; - const thinkPart: ThinkPart = { type: 'think', think: p['text'] }; - if (typeof thoughtSignature === 'string' && thoughtSignature.length > 0) { - thinkPart.encrypted = thoughtSignature; - } - parts.push(thinkPart); - } else if (p['text']) { - parts.push({ type: 'text', text: p['text'] as string }); - } else if (p['functionCall'] || p['function_call']) { - const fc = (p['functionCall'] ?? p['function_call']) as Record; - const name = fc['name'] as string; - if (!name) continue; - const id_ = (fc['id'] as string) ?? crypto.randomUUID(); - const toolCallId = `${name}_${id_}_${crypto.randomUUID().replaceAll('-', '').slice(0, 8)}`; - const thoughtSigB64 = p['thoughtSignature'] ?? p['thought_signature']; - const toolCall: ToolCall = { - type: 'function', - id: toolCallId, - name, - arguments: fc['args'] ? JSON.stringify(fc['args']) : '{}', - }; - if (typeof thoughtSigB64 === 'string' && thoughtSigB64.length > 0) { - toolCall.extras = { thought_signature_b64: thoughtSigB64 }; - } - parts.push(toolCall); - } - } - } - - return parts; - } - - private _extractUsage(response: Record): void { - const usageMetadata = response['usageMetadata'] as Record | undefined; - if (usageMetadata) { - const promptTokenCount = - typeof usageMetadata['promptTokenCount'] === 'number' - ? usageMetadata['promptTokenCount'] - : 0; - const cachedContentTokenCount = - typeof usageMetadata['cachedContentTokenCount'] === 'number' - ? usageMetadata['cachedContentTokenCount'] - : 0; - this._usage = { - inputOther: Math.max(promptTokenCount - cachedContentTokenCount, 0), - output: (usageMetadata['candidatesTokenCount'] as number) ?? 0, - inputCacheRead: cachedContentTokenCount, - inputCacheCreation: 0, - }; - } - } - - private _extractId(response: Record): void { - if (response['responseId'] !== undefined) { - this._id = response['responseId'] as string; - } - } - - private _throwIfAborted(signal: AbortSignal | undefined): void { - if (signal !== undefined && signal.aborted) { - throw createAbortError(); - } - } - - private async *_convertNonStreamResponse( - response: Record, - signal?: AbortSignal, - ): AsyncGenerator { - this._throwIfAborted(signal); - this._extractUsage(response); - this._extractId(response); - this._captureFinishReason(response); - for (const part of this._extractChunkParts(response)) { - this._throwIfAborted(signal); - yield part; - } - } - - private async *_convertStreamResponse( - response: AsyncIterable>, - signal?: AbortSignal, - ): AsyncGenerator { - try { - for await (const chunk of response) { - this._throwIfAborted(signal); - this._extractUsage(chunk); - this._extractId(chunk); - this._captureFinishReason(chunk); - for (const part of this._extractChunkParts(chunk)) { - this._throwIfAborted(signal); - yield part; - } - } - } catch (error: unknown) { - if (error instanceof DOMException && error.name === 'AbortError') { - throw error; - } - throw convertGoogleGenAIError(error); - } - } -} - -const NETWORK_RE = /network|connection|connect|disconnect|fetch failed/i; -const TIMEOUT_RE = /timed?\s*out|timeout|deadline/i; - -export function convertGoogleGenAIError(error: unknown): ChatProviderError { - if (error instanceof GoogleApiError) { - return normalizeAPIStatusError( - error.status, - error.message, - undefined, - parseRetryInfoDelayMs(error.message), - ); - } - if (error instanceof Error) { - const msg = error.message; - if (TIMEOUT_RE.test(msg)) { - return new APITimeoutError(msg); - } - if (NETWORK_RE.test(msg) || (error instanceof TypeError && msg.includes('fetch'))) { - return new APIConnectionError(msg); - } - const statusCode = (error as { code?: number }).code; - if (typeof statusCode === 'number') { - return normalizeAPIStatusError(statusCode, msg); - } - return new ChatProviderError(`GoogleGenAI error: ${msg}`); - } - return new ChatProviderError(`GoogleGenAI error: ${String(error)}`); -} - -function parseRetryInfoDelayMs(message: string): number | null { - const jsonStart = message.indexOf('{'); - if (jsonStart < 0) return null; - try { - const body: unknown = JSON.parse(message.slice(jsonStart)); - if (typeof body !== 'object' || body === null) return null; - const details = (body as { error?: { details?: unknown } }).error?.details; - if (!Array.isArray(details)) return null; - for (const detail of details) { - if (typeof detail !== 'object' || detail === null) continue; - const type = (detail as { '@type'?: unknown })['@type']; - if (typeof type !== 'string' || !type.endsWith('google.rpc.RetryInfo')) continue; - const retryDelay = (detail as { retryDelay?: unknown }).retryDelay; - if (typeof retryDelay !== 'string') continue; - const match = /^(\d+(?:\.\d+)?)s$/.exec(retryDelay.trim()); - if (match?.[1] === undefined) continue; - const seconds = Number.parseFloat(match[1]); - if (!Number.isFinite(seconds) || seconds < 0) continue; - return Math.round(seconds * 1000); - } - return null; - } catch { - return null; - } -} - -export class GoogleGenAIChatProvider implements ChatProvider { - readonly name: string = 'google_genai'; - - private readonly _model: string; - private readonly _client: GenAIClient | undefined; - private readonly _generationKwargs: GoogleGenAIGenerationKwargs; - private readonly _vertexai: boolean; - private readonly _stream: boolean; - private readonly _apiKey: string | undefined; - private readonly _baseUrl: string | undefined; - private readonly _project: string | undefined; - private readonly _location: string | undefined; - private readonly _thinkingEffort: ThinkingEffort | undefined; - private readonly _defaultHeaders: Record | undefined; - private readonly _clientFactory: ((auth: ProviderRequestAuth) => GenAIClient) | undefined; - - constructor(options: GoogleGenAIOptions) { - this._model = options.model; - this._vertexai = options.vertexai ?? false; - this._stream = options.stream ?? true; - this._thinkingEffort = options.thinkingEffort; - this._generationKwargs = {}; - - const apiKey = options.apiKey ?? process.env['GOOGLE_API_KEY']; - this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey; - this._baseUrl = - options.baseUrl === undefined || options.baseUrl.length === 0 ? undefined : options.baseUrl; - this._project = options.project; - this._location = options.location; - this._defaultHeaders = options.defaultHeaders; - this._clientFactory = options.clientFactory; - this._client = - this._vertexai || this._apiKey !== undefined ? this._buildClient(this._apiKey) : undefined; - } - - private _buildClient(apiKey: string | undefined): GenAIClient { - const httpOptions: { headers?: Record; baseUrl?: string } = {}; - if (this._defaultHeaders !== undefined) { - httpOptions.headers = this._defaultHeaders; - } - if (this._baseUrl !== undefined) { - httpOptions.baseUrl = this._baseUrl; - } - return new GenAIClient({ - apiKey, - ...(this._vertexai - ? { - vertexai: true, - project: this._project, - location: this._location, - } - : {}), - httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined, - }); - } - - get modelName(): string { - return this._model; - } - - get thinkingEffort(): ThinkingEffort | null { - return this._thinkingEffort ?? null; - } - - get maxCompletionTokens(): number | undefined { - return this._generationKwargs.maxOutputTokens; - } - - async generate( - systemPrompt: string, - tools: Tool[], - history: Message[], - options?: GenerateOptions, - ): Promise { - if (options?.signal?.aborted === true) { - throw createAbortError(); - } - - const contents = messagesToGoogleGenAIContents(history); - - let kwargs: GoogleGenAIGenerationKwargs = { ...this._generationKwargs }; - - if (options?.sampling?.temperature !== undefined) { - kwargs = { ...kwargs, temperature: options.sampling.temperature }; - } - if (options?.sampling?.topP !== undefined) { - kwargs = { ...kwargs, topP: options.sampling.topP }; - } - - const thinking = - options?.thinking ?? - (this._thinkingEffort !== undefined ? { effort: this._thinkingEffort } : undefined); - if (thinking !== undefined) { - kwargs = { ...kwargs, thinkingConfig: this._encodeThinking(thinking.effort) }; - } - - if (options?.maxCompletionTokens !== undefined) { - let cap = options.maxCompletionTokens; - if ( - options.usedContextTokens !== undefined && - options.maxContextTokens !== undefined && - options.maxContextTokens > 0 - ) { - cap = Math.min(cap, options.maxContextTokens - options.usedContextTokens); - } - kwargs = { ...kwargs, maxOutputTokens: Math.max(1, cap) }; - } - - const config: Record = { - ...kwargs, - systemInstruction: systemPrompt, - ...(tools.length > 0 ? { tools: tools.map((t) => toolToGoogleGenAI(t)) } : {}), - }; - applyResponseFormat(config, options?.responseFormat); - if (options?.extraHeaders !== undefined) { - config['httpOptions'] = { - headers: mergeRequestHeaders(this._defaultHeaders, options.extraHeaders), - }; - } - - try { - const client = this._createClient(options?.auth); - const models = client.models as unknown as { - generateContent(params: Record): Promise; - generateContentStream(params: Record): Promise; - }; - - const params = { model: this._model, contents, config }; - - options?.onRequestSent?.(); - if (this._stream) { - const stream = await Promise.race([ - models.generateContentStream(params), - abortPromise(options?.signal), - ]); - return new GoogleGenAIStreamedMessage( - stream as AsyncIterable>, - true, - options?.signal, - ); - } - - const response = await Promise.race([ - models.generateContent(params), - abortPromise(options?.signal), - ]); - return new GoogleGenAIStreamedMessage( - response as Record, - false, - options?.signal, - ); - } catch (error: unknown) { - if (error instanceof DOMException && error.name === 'AbortError') { - throw error; - } - throw convertGoogleGenAIError(error); - } - } - - private _encodeThinking(effort: ThinkingEffort): ThinkingConfig { - const thinkingConfig: ThinkingConfig = { includeThoughts: true }; - - if (this._model.includes('gemini-3')) { - switch (effort) { - case 'off': - thinkingConfig.thinkingLevel = 'MINIMAL'; - thinkingConfig.includeThoughts = false; - break; - case 'low': - thinkingConfig.thinkingLevel = 'LOW'; - break; - case 'medium': - thinkingConfig.thinkingLevel = 'MEDIUM'; - break; - case 'high': - case 'xhigh': - case 'max': - thinkingConfig.thinkingLevel = 'HIGH'; - break; - } - } else { - switch (effort) { - case 'off': - thinkingConfig.thinkingBudget = 0; - thinkingConfig.includeThoughts = false; - break; - case 'low': - thinkingConfig.thinkingBudget = 1024; - thinkingConfig.includeThoughts = true; - break; - case 'medium': - thinkingConfig.thinkingBudget = 4096; - thinkingConfig.includeThoughts = true; - break; - case 'high': - case 'xhigh': - case 'max': - thinkingConfig.thinkingBudget = 32_000; - thinkingConfig.includeThoughts = true; - break; - } - } - - return thinkingConfig; - } - - private _createClient(auth: ProviderRequestAuth | undefined): GenAIClient { - return resolveAuthBackedClient( - { cachedClient: this._client, clientFactory: this._clientFactory }, - auth, - (a) => { - if (this._vertexai) return this._buildClient(this._apiKey); - return this._buildClient(requireProviderApiKey('GoogleGenAIChatProvider', a, this._apiKey)); - }, - ); - } -} - -const GEMINI_CATALOGUED_PREFIXES = [ - 'gemini-1.5-pro', - 'gemini-1.5-flash', - 'gemini-2.0-flash', - 'gemini-2.0-pro', - 'gemini-2.5-pro', - 'gemini-2.5-flash', -] as const; - -const GEMINI_MULTIMODAL_TOOL_CAPABILITY = Object.freeze({ - image_in: true, - video_in: true, - audio_in: true, - thinking: false, - tool_use: true, - max_context_tokens: 0, -}); - -const GEMINI_THINKING_MULTIMODAL_TOOL_CAPABILITY = Object.freeze({ - image_in: true, - video_in: true, - audio_in: true, - thinking: true, - tool_use: true, - max_context_tokens: 0, -}); - -export function getGoogleGenAIModelCapability(modelName: string) { - const normalized = modelName.toLowerCase(); - if (!normalized.startsWith('gemini-')) return undefined; - if (!GEMINI_CATALOGUED_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return undefined; - } - - if (normalized.startsWith('gemini-2.5-') || normalized.includes('thinking')) { - return GEMINI_THINKING_MULTIMODAL_TOOL_CAPABILITY; - } - return GEMINI_MULTIMODAL_TOOL_CAPABILITY; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/index.ts b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/index.ts deleted file mode 100644 index 47f0cef59..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './google-genai.contrib'; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/merge-user-messages.ts b/packages/agent-core-v2/src/kosong/provider/bases/merge-user-messages.ts deleted file mode 100644 index 6a3fa5861..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/merge-user-messages.ts +++ /dev/null @@ -1,25 +0,0 @@ -export function mergeConsecutiveUserMessages( - messages: readonly T[], - mergePolicy: { - readonly isUser: (message: T) => boolean; - readonly isToolResultOnly: (message: T) => boolean; - readonly merge: (last: T, next: T) => T; - }, -): T[] { - const out: T[] = []; - for (const message of messages) { - const lastIndex = out.length - 1; - const last = lastIndex >= 0 ? out[lastIndex] : undefined; - if ( - last !== undefined && - mergePolicy.isUser(last) && - mergePolicy.isUser(message) && - (mergePolicy.isToolResultOnly(last) || !mergePolicy.isToolResultOnly(message)) - ) { - out[lastIndex] = mergePolicy.merge(last, message); - } else { - out.push(message); - } - } - return out; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/chat-completions-stream.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/chat-completions-stream.ts deleted file mode 100644 index 287c7f9c0..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/chat-completions-stream.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { StreamedMessagePart, ToolCall } from '#/kosong/contract/message'; - -export interface ChatCompletionStreamToolFunctionDelta { - readonly name?: string; - readonly arguments?: string; -} - -export interface ChatCompletionStreamToolCallDelta { - readonly index?: number | string; - readonly id?: string; - readonly function?: ChatCompletionStreamToolFunctionDelta | null; -} - -export interface BufferedChatCompletionToolCall { - id?: string; - arguments: string; - emitted: boolean; -} - -export function convertChatCompletionStreamToolCall( - toolCall: ChatCompletionStreamToolCallDelta, - bufferedByIndex: Map, -): StreamedMessagePart[] { - if (toolCall.function === undefined || toolCall.function === null) { - return []; - } - - const streamIndex = toolCall.index; - const functionName = toolCall.function.name; - const functionArguments = toolCall.function.arguments; - const hasConcreteName = typeof functionName === 'string' && functionName.length > 0; - const hasArguments = typeof functionArguments === 'string' && functionArguments.length > 0; - - if (streamIndex === undefined) { - if (hasConcreteName) { - return [ - { - type: 'function', - id: toolCall.id ?? crypto.randomUUID(), - name: functionName, - arguments: functionArguments ?? null, - } satisfies ToolCall, - ]; - } - - if (hasArguments) { - return [ - { type: 'tool_call_part', argumentsPart: functionArguments } satisfies StreamedMessagePart, - ]; - } - - return []; - } - - const buffered = bufferedByIndex.get(streamIndex) ?? { arguments: '', emitted: false }; - if (toolCall.id !== undefined) { - buffered.id = toolCall.id; - } - - if (!buffered.emitted) { - if (!hasConcreteName) { - if (hasArguments) { - buffered.arguments += functionArguments; - } - bufferedByIndex.set(streamIndex, buffered); - return []; - } - - buffered.emitted = true; - const initialArguments = - buffered.arguments.length > 0 - ? buffered.arguments + (functionArguments ?? '') - : (functionArguments ?? null); - buffered.arguments = ''; - bufferedByIndex.set(streamIndex, buffered); - - const toolCallHeader: ToolCall = { - type: 'function', - id: buffered.id ?? toolCall.id ?? crypto.randomUUID(), - name: functionName, - arguments: initialArguments, - _streamIndex: streamIndex, - }; - return [toolCallHeader]; - } - - if (!hasArguments) { - return []; - } - - const part: StreamedMessagePart & { index: number | string } = { - type: 'tool_call_part', - argumentsPart: functionArguments, - index: streamIndex, - }; - return [part]; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/index.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/index.ts deleted file mode 100644 index 383da627d..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import './openai-legacy.contrib'; -import './openai-responses.contrib'; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts deleted file mode 100644 index fb751e3bb..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { - APIConnectionError as OpenAIConnectionError, - APIConnectionTimeoutError as OpenAITimeoutError, - APIError as OpenAIAPIError, - OpenAIError, -} from 'openai'; - -import { BugIndicatingError } from '#/_base/errors/errors'; -import { - APIConnectionError, - APIProviderQuotaExhaustedError, - APIStatusError, - APITimeoutError, - ChatProviderError, - PROVIDER_API_ERROR_CODE, - classifyBaseApiError, - normalizeAPIStatusError, - parseRetryAfterMs, - parseTraceId, - throwIfAbortError, -} from '#/kosong/contract/errors'; -import { extractText } from '#/kosong/contract/message'; -import type { ContentPart, Message } from '#/kosong/contract/message'; -import type { FinishReason } from '#/kosong/contract/provider'; -import type { Tool } from '#/kosong/contract/tool'; -import type { TokenUsage } from '#/kosong/contract/usage'; - -export interface OpenAIContentPart { - type: string; - text?: string | undefined; - image_url?: { url: string; id?: string | null } | undefined; - audio_url?: { url: string; id?: string | null } | undefined; - video_url?: { url: string; id?: string | null } | undefined; -} - -export function convertContentPart(part: ContentPart): OpenAIContentPart | null { - switch (part.type) { - case 'text': - return { type: 'text', text: part.text }; - case 'think': - return null; - case 'image_url': - return { - type: 'image_url', - image_url: - part.imageUrl.id === undefined - ? { url: part.imageUrl.url } - : { url: part.imageUrl.url, id: part.imageUrl.id }, - }; - case 'audio_url': - return { - type: 'audio_url', - audio_url: - part.audioUrl.id === undefined - ? { url: part.audioUrl.url } - : { url: part.audioUrl.url, id: part.audioUrl.id }, - }; - case 'video_url': - return { - type: 'video_url', - video_url: - part.videoUrl.id === undefined - ? { url: part.videoUrl.url } - : { url: part.videoUrl.url, id: part.videoUrl.id }, - }; - default: - throw new BugIndicatingError(`Unknown content part type: ${(part as ContentPart).type}`); - } -} - -export type OpenAIToolParam = { - type: string; - function: { - name: string; - description?: string; - parameters?: Record; - }; -}; - -export function toolToOpenAI(tool: Tool): OpenAIToolParam { - return { - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters, - }, - }; -} - -export function isOpenAIInsufficientQuotaCode(code: string | null | undefined): boolean { - return code === 'insufficient_quota'; -} - -function isOpenAIInsufficientQuotaError(error: OpenAIAPIError): boolean { - if (![401, 402, 403, 429].includes(error.status ?? 0)) return false; - if (typeof error.code === 'string' && isOpenAIInsufficientQuotaCode(error.code)) return true; - if (typeof error.type === 'string' && isOpenAIInsufficientQuotaCode(error.type)) return true; - const message = error.message.toLowerCase(); - if (message.includes('insufficient_quota')) return true; - if (error.status === 429) return false; - return ( - message.includes('insufficient balance') || - message.includes('insufficient credit') || - message.includes('credits exhausted') || - message.includes('please recharge') - ); -} - -export function convertOpenAIError( - error: unknown, - convertErrorHook?: (error: unknown) => ChatProviderError | undefined, -): ChatProviderError { - throwIfAbortError(error); - if (error instanceof ChatProviderError) { - return error; - } - const hooked = convertErrorHook?.(error); - if (hooked !== undefined) { - return hooked; - } - if (error instanceof OpenAITimeoutError) { - return new APITimeoutError(error.message); - } - if (error instanceof OpenAIConnectionError) { - return new APIConnectionError(error.message); - } - if (error instanceof OpenAIAPIError && typeof error.status === 'number') { - const reqId = error.requestID ?? null; - const retryAfterMs = parseRetryAfterMs(error.headers); - const traceId = parseTraceId(error.headers); - if (isOpenAIInsufficientQuotaError(error)) { - if (error.status === 429) { - return new APIProviderQuotaExhaustedError(error.message, reqId, retryAfterMs, traceId); - } - return new APIStatusError( - error.status, - error.message, - reqId, - retryAfterMs, - traceId, - PROVIDER_API_ERROR_CODE, - ); - } - return normalizeAPIStatusError(error.status, error.message, reqId, retryAfterMs, traceId); - } - if ( - error instanceof OpenAIAPIError && - error.constructor === OpenAIAPIError && - error.error === undefined - ) { - return classifyBaseApiError(error.message); - } - if (error instanceof OpenAIError) { - return new ChatProviderError(`Error: ${error.message}`); - } - if (error instanceof Error) { - return classifyBaseApiError(error.message); - } - return new ChatProviderError(`Error: ${String(error)}`); -} - -export interface FunctionToolCallShape { - type: 'function'; - id: string; - function: { name: string; arguments: string | null }; -} - -export function isFunctionToolCall( - tc: T, -): tc is T & FunctionToolCallShape { - return tc.type === 'function'; -} - -export function extractUsage(usage: unknown): TokenUsage | null { - if (usage === null || usage === undefined || typeof usage !== 'object') { - return null; - } - const u = usage as Record; - const promptTokens = typeof u['prompt_tokens'] === 'number' ? u['prompt_tokens'] : 0; - const completionTokens = typeof u['completion_tokens'] === 'number' ? u['completion_tokens'] : 0; - - let cached = 0; - if (typeof u['cached_tokens'] === 'number') { - cached = u['cached_tokens']; - } else if ( - typeof u['prompt_tokens_details'] === 'object' && - u['prompt_tokens_details'] !== null - ) { - const details = u['prompt_tokens_details'] as Record; - if (typeof details['cached_tokens'] === 'number') { - cached = details['cached_tokens']; - } - } - - return { - inputOther: promptTokens - cached, - output: completionTokens, - inputCacheRead: cached, - inputCacheCreation: 0, - }; -} - -export function normalizeOpenAIFinishReason(raw: string | null | undefined): { - finishReason: FinishReason | null; - rawFinishReason: string | null; -} { - if (raw === null || raw === undefined) { - return { finishReason: null, rawFinishReason: null }; - } - switch (raw) { - case 'stop': - return { finishReason: 'completed', rawFinishReason: raw }; - case 'tool_calls': - case 'function_call': - return { finishReason: 'tool_calls', rawFinishReason: raw }; - case 'length': - return { finishReason: 'truncated', rawFinishReason: raw }; - case 'content_filter': - return { finishReason: 'filtered', rawFinishReason: raw }; - default: - return { finishReason: 'other', rawFinishReason: raw }; - } -} - -export type ToolMessageConversion = 'extract_text' | null; - -export const TOOL_RESULT_MEDIA_PROMPT = 'Attached media from tool result:'; -export const TOOL_RESULT_MEDIA_PLACEHOLDER = '(see attached media)'; - -export function isMediaPart(part: ContentPart): boolean { - return part.type !== 'text' && part.type !== 'think'; -} - -export function convertToolMessageContent( - message: Message, - conversion: ToolMessageConversion, -): string | OpenAIContentPart[] { - if (conversion === 'extract_text') { - return extractText(message); - } - return message.content - .map((p) => convertContentPart(p)) - .filter((p): p is OpenAIContentPart => p !== null); -} - -export const OPENAI_REASONING_CAPABILITY = Object.freeze({ - image_in: false, - video_in: false, - audio_in: false, - thinking: true, - tool_use: true, - max_context_tokens: 0, -}); - -export const OPENAI_VISION_TOOL_CAPABILITY = Object.freeze({ - image_in: true, - video_in: false, - audio_in: false, - thinking: false, - tool_use: true, - max_context_tokens: 0, -}); - -export const OPENAI_TEXT_TOOL_CAPABILITY = Object.freeze({ - image_in: false, - video_in: false, - audio_in: false, - thinking: false, - tool_use: true, - max_context_tokens: 0, -}); - -export const OPENAI_VISION_TOOL_PREFIXES = ['gpt-4o', 'gpt-4-turbo', 'gpt-4.1', 'gpt-4.5'] as const; - -export function isOpenAIReasoningModel(normalizedModelName: string): boolean { - return /^o\d/.test(normalizedModelName); -} - -export function hasModelPrefix(modelName: string, prefixes: readonly string[]): boolean { - return prefixes.some((prefix) => modelName.startsWith(prefix)); -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.contrib.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.contrib.ts deleted file mode 100644 index d89429a8e..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.contrib.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { registerProtocolBase } from '#/kosong/protocol/protocolBase'; -import { traitDefaultHeaders } from '#/kosong/protocol/protocolTrait'; - -import { getOpenAILegacyModelCapability, OpenAILegacyChatProvider } from './openai-legacy'; -import { - compactObject, - composeOpenAIChatHooks, - firstProcessEnv, - traitEndpoint, - traitProvides, -} from './openaiHooks'; - -registerProtocolBase({ - id: 'openai', - capability: getOpenAILegacyModelCapability, - createChatProvider({ config, traits }) { - const endpoint = traitEndpoint(traits); - return new OpenAILegacyChatProvider({ - ...(traitProvides(traits) as Partial< - ConstructorParameters[0] - >), - model: config.modelName, - ...compactObject({ - apiKey: - config.apiKey ?? - firstProcessEnv(endpoint?.apiKeyEnv) ?? - (endpoint === undefined ? undefined : ''), - baseUrl: - config.baseUrl ?? firstProcessEnv(endpoint?.baseUrlEnv) ?? endpoint?.defaultBaseUrl, - defaultHeaders: traitDefaultHeaders(traits), - maxTokens: config.providerOptions?.defaultMaxTokens, - reasoningKey: config.providerOptions?.reasoningKey, - offEffort: config.providerOptions?.offEffort, - hooks: composeOpenAIChatHooks(traits), - }), - }); - }, -}); diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts deleted file mode 100644 index 867aefe82..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts +++ /dev/null @@ -1,819 +0,0 @@ -import OpenAI from 'openai'; - -import { parseTraceId, type ChatProviderError } from '#/kosong/contract/errors'; -import type { - ContentPart, - Message, - StreamedMessagePart, - ToolCall, - VideoURLPart, -} from '#/kosong/contract/message'; -import { isToolDeclarationOnlyMessage } from '#/kosong/contract/message'; -import type { - ChatProvider, - FinishReason, - GenerateOptions, - ProviderRequestAuth, - ResponseFormat, - StreamedMessage, - ThinkingEffort, - ToolCallIdPolicy, - VideoUploadInput, -} from '#/kosong/contract/provider'; -import type { Tool } from '#/kosong/contract/tool'; -import type { TokenUsage } from '#/kosong/contract/usage'; - -import { - convertChatCompletionStreamToolCall, - type BufferedChatCompletionToolCall, -} from './chat-completions-stream'; -import { - convertContentPart, - convertOpenAIError, - convertToolMessageContent, - extractUsage, - hasModelPrefix, - isFunctionToolCall, - isOpenAIReasoningModel, - normalizeOpenAIFinishReason, - OPENAI_REASONING_CAPABILITY, - OPENAI_TEXT_TOOL_CAPABILITY, - OPENAI_VISION_TOOL_CAPABILITY, - OPENAI_VISION_TOOL_PREFIXES, - type OpenAIContentPart, - TOOL_RESULT_MEDIA_PLACEHOLDER, - TOOL_RESULT_MEDIA_PROMPT, - type ToolMessageConversion, - toolToOpenAI, -} from './openai-common'; -import { ReasoningKeyDialect } from './reasoning-key'; -import { DsmlStreamParser, extractDsmlToolCalls } from './dsml-tool-parser'; -import { - mergeRequestHeaders, - requireProviderApiKey, - resolveAuthBackedClient, -} from '../request-auth'; -import { normalizeToolCallIdsForProvider, sanitizeToolCallId } from '../tool-call-id'; - -const CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING = 128 * 1024; - -export const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { - normalize: (id) => sanitizeToolCallId(id, 64), - maxLength: 64, -}; - -export interface OpenAIChatCompletionsHooks { - convertTool?: (tool: Tool) => Record | undefined; - convertError?: (error: unknown) => ChatProviderError | undefined; - convertMessage?: ( - message: Message, - converted: Record, - ) => Record | null; - mergeHistory?: ( - messages: readonly Record[], - ) => Record[] | undefined; - buildParams?: (params: Record) => Record | undefined; - toolCallIdPolicy?: () => ToolCallIdPolicy | undefined; - withThinking?: ( - effort: ThinkingEffort, - options: { readonly keep?: string }, - generationKwargs: OpenAILegacyGenerationKwargs, - ) => OpenAILegacyGenerationKwargs | undefined; - preserveThinking?: (generationKwargs: Record) => boolean | undefined; - withMaxCompletionTokens?: (maxCompletionTokens: number) => Record | undefined; - cacheKey?: (key: string) => Record | undefined; - extractUsage?: (chunk: Record) => Record | null | undefined; - reasoningKey?: () => string | undefined; - uploadVideo?: ( - input: string | VideoUploadInput, - options?: GenerateOptions, - ) => Promise; -} - -export interface OpenAILegacyOptions { - apiKey?: string | undefined; - baseUrl?: string | undefined; - model: string; - stream?: boolean | undefined; - maxTokens?: number | undefined; - reasoningKey?: string | undefined; - offEffort?: string | undefined; - thinkingEffort?: ThinkingEffort | undefined; - httpClient?: unknown; - defaultHeaders?: Record; - toolMessageConversion?: ToolMessageConversion | undefined; - clientFactory?: (auth: ProviderRequestAuth) => OpenAI; - hooks?: OpenAIChatCompletionsHooks | undefined; -} - -export interface OpenAILegacyGenerationKwargs { - max_tokens?: number | undefined; - max_completion_tokens?: number | undefined; - temperature?: number | undefined; - top_p?: number | undefined; - n?: number | undefined; - presence_penalty?: number | undefined; - frequency_penalty?: number | undefined; - stop?: string | string[] | undefined; - [key: string]: unknown; -} - -interface OpenAIMessage { - role: string; - content?: string | OpenAIContentPart[] | null | undefined; - tool_calls?: OpenAIToolCallOut[] | undefined; - tool_call_id?: string | undefined; - name?: string | undefined; - [key: string]: unknown; -} - -interface OpenAIToolCallOut { - type: string; - id: string; - function: { name: string; arguments: string | null }; -} - -function usesMaxCompletionTokens(model: string): boolean { - const normalized = model.toLowerCase(); - return /^o\d(?:$|[-.])/.test(normalized) || /^gpt-5(?:$|[-.])/.test(normalized); -} - -function completionTokenKwargs( - model: string, - maxCompletionTokens: number, -): OpenAILegacyGenerationKwargs { - return usesMaxCompletionTokens(model) - ? { max_completion_tokens: maxCompletionTokens } - : { max_tokens: maxCompletionTokens }; -} - -function normalizeGenerationKwargs( - model: string, - source: OpenAILegacyGenerationKwargs, -): OpenAILegacyGenerationKwargs { - const kwargs = { ...source }; - if (usesMaxCompletionTokens(model)) { - if (kwargs.max_completion_tokens === undefined && kwargs.max_tokens !== undefined) { - kwargs.max_completion_tokens = kwargs.max_tokens; - } - delete kwargs.max_tokens; - } - return kwargs; -} - -function responseFormatToOpenAI(format: ResponseFormat): Record { - if (format.type === 'json_object') { - return { type: 'json_object' }; - } - return { - type: 'json_schema', - json_schema: { - name: format.jsonSchema.name, - schema: format.jsonSchema.schema, - strict: format.jsonSchema.strict, - description: format.jsonSchema.description, - }, - }; -} - -function convertMessage( - message: Message, - reasoningKey: string, - toolMessageConversion: ToolMessageConversion, - preserveThinking: boolean, - allowToolResultExtraction: boolean, -): OpenAIMessage { - let reasoningContent = ''; - let hasReasoningPart = false; - const nonThinkParts: ContentPart[] = []; - - for (const part of message.content) { - if (part.type === 'think') { - hasReasoningPart = true; - reasoningContent += part.think; - } else { - nonThinkParts.push(part); - } - } - - const result: OpenAIMessage = { role: message.role }; - - if (message.role === 'tool') { - const hasNonTextPart = message.content.some((p) => p.type !== 'text' && p.type !== 'think'); - const effectiveConversion: ToolMessageConversion = - allowToolResultExtraction && hasNonTextPart ? 'extract_text' : toolMessageConversion; - - if (effectiveConversion !== null) { - result.content = convertToolMessageContentForChat(message, effectiveConversion); - } else { - const firstPart = nonThinkParts[0]; - if (nonThinkParts.length === 1 && firstPart?.type === 'text') { - result.content = firstPart.text; - } else if (nonThinkParts.length > 0) { - result.content = nonThinkParts - .map((p) => convertContentPart(p)) - .filter((p): p is OpenAIContentPart => p !== null); - } - } - } else { - const firstPart = nonThinkParts[0]; - if (nonThinkParts.length === 1 && firstPart?.type === 'text') { - result.content = firstPart.text; - } else if (nonThinkParts.length > 0) { - result.content = nonThinkParts - .map((p) => convertContentPart(p)) - .filter((p): p is OpenAIContentPart => p !== null); - } - } - - if (message.name !== undefined) { - result.name = message.name; - } - - if (message.toolCalls.length > 0) { - result.tool_calls = message.toolCalls.map((tc) => ({ - type: tc.type, - id: tc.id, - function: { name: tc.name, arguments: tc.arguments }, - })); - } - - if (message.toolCallId !== undefined) { - result.tool_call_id = message.toolCallId; - } - - if ( - message.role === 'assistant' && - hasReasoningPart && - result.content === undefined && - result.tool_calls === undefined - ) { - result.content = ''; - } - - if (message.role === 'assistant' && result.content === undefined) { - result.content = null; - } - - if (hasReasoningPart || (preserveThinking && message.role === 'assistant')) { - result[reasoningKey] = reasoningContent; - } - - return result; -} - -const OMITTED_AUDIO_PLACEHOLDER = '(audio omitted: not supported by this provider)'; -const OMITTED_VIDEO_PLACEHOLDER = '(video omitted: not supported by this provider)'; - -function convertToolMessageContentForChat( - message: Message, - conversion: ToolMessageConversion, -): string | OpenAIContentPart[] { - const content = convertToolMessageContent(message, conversion); - if (typeof content !== 'string') { - return content; - } - const lines: string[] = content.length > 0 ? [content] : []; - if (message.content.some((part) => part.type === 'audio_url')) { - lines.push(OMITTED_AUDIO_PLACEHOLDER); - } - if (message.content.some((part) => part.type === 'video_url')) { - lines.push(OMITTED_VIDEO_PLACEHOLDER); - } - if (lines.length === 0 && message.content.some((part) => part.type === 'image_url')) { - return TOOL_RESULT_MEDIA_PLACEHOLDER; - } - return lines.join('\n'); -} - -function toolResultImageParts(message: Message): OpenAIContentPart[] { - const images: OpenAIContentPart[] = []; - for (const part of message.content) { - if (part.type !== 'image_url') continue; - const converted = convertContentPart(part); - if (converted !== null) { - images.push(converted); - } - } - return images; -} - -function appendToolResultMediaMessage( - messages: OpenAIMessage[], - pendingToolResultMedia: OpenAIContentPart[], -): void { - if (pendingToolResultMedia.length === 0) return; - messages.push({ - role: 'user', - content: [{ type: 'text', text: TOOL_RESULT_MEDIA_PROMPT }, ...pendingToolResultMedia], - }); - pendingToolResultMedia.length = 0; -} - -function convertHistoryMessages( - history: readonly Message[], - reasoningKey: string, - toolMessageConversion: ToolMessageConversion, - preserveThinking: boolean, -): OpenAIMessage[] { - const messages: OpenAIMessage[] = []; - const pendingToolResultMedia: OpenAIContentPart[] = []; - - for (const msg of history) { - if (isToolDeclarationOnlyMessage(msg)) continue; - if (msg.role !== 'tool') { - appendToolResultMediaMessage(messages, pendingToolResultMedia); - } - messages.push(convertMessage(msg, reasoningKey, toolMessageConversion, preserveThinking, true)); - if (msg.role === 'tool') { - pendingToolResultMedia.push(...toolResultImageParts(msg)); - } - } - - appendToolResultMediaMessage(messages, pendingToolResultMedia); - return messages; -} - -export class OpenAILegacyStreamedMessage implements StreamedMessage { - private _id: string | null = null; - private _usage: TokenUsage | null = null; - private _finishReason: FinishReason | null = null; - private _rawFinishReason: string | null = null; - private _hasExtractedToolCalls = false; - private readonly _iter: AsyncGenerator; - - constructor( - response: OpenAI.Chat.ChatCompletion | AsyncIterable, - isStream: boolean, - reasoningKeyDialect: ReasoningKeyDialect, - private readonly _traceId: string | null, - private readonly _extractUsageHook?: - | ((chunk: Record) => Record | null | undefined) - | undefined, - private readonly _convertErrorHook?: - | ((error: unknown) => ChatProviderError | undefined) - | undefined, - ) { - if (isStream) { - this._iter = this._convertStreamResponse( - response as AsyncIterable, - reasoningKeyDialect, - ); - } else { - this._iter = this._convertNonStreamResponse( - response as OpenAI.Chat.ChatCompletion, - reasoningKeyDialect, - ); - } - } - - get id(): string | null { - return this._id; - } - - get usage(): TokenUsage | null { - return this._usage; - } - - get finishReason(): FinishReason | null { - if ( - this._hasExtractedToolCalls && - (this._finishReason === 'completed' || this._finishReason === null) - ) { - return 'tool_calls'; - } - return this._finishReason; - } - - get rawFinishReason(): string | null { - return this._rawFinishReason; - } - - get traceId(): string | null { - return this._traceId; - } - - async *[Symbol.asyncIterator](): AsyncIterator { - yield* this._iter; - } - - private _captureFinishReason(raw: string | null | undefined): void { - const normalized = normalizeOpenAIFinishReason(raw); - this._finishReason = normalized.finishReason; - this._rawFinishReason = normalized.rawFinishReason; - } - - private _captureUsage(raw: Record, fallback: unknown): void { - const hooked = this._extractUsageHook?.(raw); - const rawUsage = hooked !== undefined ? hooked : fallback; - if (rawUsage !== null && rawUsage !== undefined) { - this._usage = extractUsage(rawUsage) ?? null; - } - } - - private async *_convertNonStreamResponse( - response: OpenAI.Chat.ChatCompletion, - reasoningKeyDialect: ReasoningKeyDialect, - ): AsyncGenerator { - this._id = response.id; - this._captureUsage(response as unknown as Record, response.usage); - this._captureFinishReason(response.choices[0]?.finish_reason ?? null); - - const message = response.choices[0]?.message; - if (!message) return; - - const reasoning = reasoningKeyDialect.observe(message); - if (reasoning !== undefined) { - yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; - } - - let text = message.content ?? null; - let extractedToolCalls: ToolCall[] = []; - const hasNativeToolCalls = (message.tool_calls ?? []).some(isFunctionToolCall); - if (text && !hasNativeToolCalls) { - const parsed = extractDsmlToolCalls(text); - if (parsed.toolCalls.length > 0) { - text = parsed.cleanText; - extractedToolCalls = parsed.toolCalls; - this._hasExtractedToolCalls = true; - } - } - - if (text && text.length > 0) { - yield { type: 'text', text } satisfies StreamedMessagePart; - } - - if (message.tool_calls) { - for (const toolCall of message.tool_calls) { - if (!isFunctionToolCall(toolCall)) continue; - yield { - type: 'function', - id: toolCall.id || crypto.randomUUID(), - name: toolCall.function.name, - arguments: toolCall.function.arguments, - } satisfies ToolCall; - } - } - - for (const toolCall of extractedToolCalls) { - yield toolCall satisfies ToolCall; - } - } - - private async *_convertStreamResponse( - response: AsyncIterable, - reasoningKeyDialect: ReasoningKeyDialect, - ): AsyncGenerator { - const bufferedToolCalls = new Map(); - const dsmlParser = new DsmlStreamParser(); - const recoveredToolCalls: ToolCall[] = []; - let nativeToolCallsSeen = false; - - try { - for await (const chunk of response) { - if (chunk.id) { - this._id = chunk.id; - } - - this._captureUsage(chunk as unknown as Record, chunk.usage); - - if (!chunk.choices || chunk.choices.length === 0) { - continue; - } - - const choice = chunk.choices[0]; - if (!choice) continue; - - if (choice.finish_reason !== null && choice.finish_reason !== undefined) { - this._captureFinishReason(choice.finish_reason); - } - - const delta = choice.delta; - - const reasoning = reasoningKeyDialect.observe(delta); - if (reasoning !== undefined) { - yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; - } - - if (delta.content) { - for (const part of dsmlParser.feed(delta.content)) { - if (part.type === 'function') { - recoveredToolCalls.push(part); - } else { - yield part; - } - } - } - - for (const toolCall of delta.tool_calls ?? []) { - nativeToolCallsSeen = true; - for (const part of convertChatCompletionStreamToolCall(toolCall, bufferedToolCalls)) { - yield part; - } - } - } - - for (const part of dsmlParser.flush()) { - if (part.type === 'function') { - recoveredToolCalls.push(part); - } else { - yield part; - } - } - if (!nativeToolCallsSeen && recoveredToolCalls.length > 0) { - this._hasExtractedToolCalls = true; - for (const toolCall of recoveredToolCalls) { - yield toolCall; - } - } - } catch (error: unknown) { - throw convertOpenAIError(error, this._convertErrorHook); - } - } -} - -export class OpenAILegacyChatProvider implements ChatProvider { - readonly name: string = 'openai'; - - private readonly _model: string; - private readonly _stream: boolean; - private readonly _apiKey: string | undefined; - private readonly _baseUrl: string | undefined; - private readonly _defaultHeaders: Record | undefined; - private readonly _reasoningKeyDialect: ReasoningKeyDialect; - private readonly _offEffort: string | undefined; - private readonly _thinkingEffort: ThinkingEffort | undefined; - private readonly _generationKwargs: OpenAILegacyGenerationKwargs; - private readonly _toolMessageConversion: ToolMessageConversion; - private readonly _client: OpenAI | undefined; - private readonly _httpClient: unknown; - private readonly _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined; - private readonly _hooks: OpenAIChatCompletionsHooks | undefined; - - readonly uploadVideo?: ( - input: string | VideoUploadInput, - options?: GenerateOptions, - ) => Promise; - - constructor(options: OpenAILegacyOptions) { - const apiKey = options.apiKey ?? process.env['OPENAI_API_KEY']; - this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey; - this._baseUrl = options.baseUrl ?? 'https://api.openai.com/v1'; - this._defaultHeaders = options.defaultHeaders; - this._model = options.model; - this._stream = options.stream ?? true; - this._hooks = options.hooks; - const normalizedReasoningKey = options.reasoningKey?.trim(); - this._reasoningKeyDialect = new ReasoningKeyDialect( - normalizedReasoningKey !== undefined && normalizedReasoningKey.length > 0 - ? normalizedReasoningKey - : this._hooks?.reasoningKey?.(), - ); - this._thinkingEffort = options.thinkingEffort; - this._offEffort = options.offEffort; - this._generationKwargs = normalizeGenerationKwargs( - this._model, - options.maxTokens !== undefined ? completionTokenKwargs(this._model, options.maxTokens) : {}, - ); - this._toolMessageConversion = options.toolMessageConversion ?? null; - this._httpClient = options.httpClient; - this._clientFactory = options.clientFactory; - - this._client = this._apiKey === undefined ? undefined : this._buildClient(this._apiKey); - - const uploadVideo = this._hooks?.uploadVideo; - if (uploadVideo !== undefined) { - this.uploadVideo = (input, generateOptions) => uploadVideo(input, generateOptions); - } - } - - get modelName(): string { - return this._model; - } - - get thinkingEffort(): ThinkingEffort | null { - return this._thinkingEffort ?? null; - } - - get maxCompletionTokens(): number | undefined { - return this._generationKwargs.max_completion_tokens ?? this._generationKwargs.max_tokens; - } - - async generate( - systemPrompt: string, - tools: Tool[], - history: Message[], - options?: GenerateOptions, - ): Promise { - const { kwargs, reasoningEffort } = this._resolveRequestKwargs(history, options); - - const preserveThinking = this._hooks?.preserveThinking?.(kwargs) ?? false; - const reasoningKey = this._reasoningKeyDialect.outboundKey(); - - const messages: Record[] = []; - if (systemPrompt) { - messages.push({ role: 'system', content: systemPrompt }); - } - - const policy = this._hooks?.toolCallIdPolicy?.() ?? OPENAI_CHAT_TOOL_CALL_ID_POLICY; - const normalizedHistory = normalizeToolCallIdsForProvider(history, policy); - - const convertMessageHook = this._hooks?.convertMessage; - if (convertMessageHook !== undefined) { - for (const msg of normalizedHistory) { - const converted = convertMessage(msg, reasoningKey, null, preserveThinking, false); - const shaped = convertMessageHook(msg, converted); - if (shaped !== null) { - messages.push(shaped); - } - } - } else { - messages.push( - ...convertHistoryMessages( - normalizedHistory, - reasoningKey, - this._toolMessageConversion, - preserveThinking, - ), - ); - } - - const merged = this._hooks?.mergeHistory?.(messages); - const finalMessages = merged ?? messages; - - const createParams: Record = { - model: this._model, - messages: finalMessages, - stream: this._stream, - ...kwargs, - }; - - if (tools.length > 0) { - const convertTool = this._hooks?.convertTool ?? ((tool: Tool) => toolToOpenAI(tool)); - createParams['tools'] = tools.map((tool) => convertTool(tool)); - } - if (options?.responseFormat !== undefined) { - createParams['response_format'] = responseFormatToOpenAI(options.responseFormat); - } - - if (this._stream) { - createParams['stream_options'] = { include_usage: true }; - } - - if (reasoningEffort !== undefined) { - createParams['reasoning_effort'] = reasoningEffort; - } - - const builtParams = this._hooks?.buildParams?.(createParams); - const finalParams = builtParams ?? createParams; - - try { - const client = this._createClient(options?.auth); - options?.onRequestSent?.(); - const requestOptions: Record = {}; - if (options?.signal) { - requestOptions['signal'] = options.signal; - } - if (options?.extraHeaders !== undefined) { - requestOptions['headers'] = options.extraHeaders; - } - const { data, response } = await client.chat.completions - .create( - finalParams as unknown as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming, - Object.keys(requestOptions).length > 0 ? requestOptions : undefined, - ) - .withResponse(); - return new OpenAILegacyStreamedMessage( - data as unknown as - | OpenAI.Chat.ChatCompletion - | AsyncIterable, - this._stream, - this._reasoningKeyDialect, - parseTraceId(response.headers), - this._hooks?.extractUsage, - this._hooks?.convertError, - ); - } catch (error: unknown) { - throw convertOpenAIError(error, this._hooks?.convertError); - } - } - - private _resolveRequestKwargs( - history: readonly Message[], - options: GenerateOptions | undefined, - ): { kwargs: Record; reasoningEffort: string | undefined } { - let kwargs: Record = { ...this._generationKwargs }; - - if (options?.cacheKey !== undefined) { - const hooked = this._hooks?.cacheKey?.(options.cacheKey); - kwargs = { ...kwargs, ...(hooked ?? { prompt_cache_key: options.cacheKey }) }; - } - - if (options?.sampling?.temperature !== undefined) { - kwargs = { ...kwargs, temperature: options.sampling.temperature }; - } - if (options?.sampling?.topP !== undefined) { - kwargs = { ...kwargs, top_p: options.sampling.topP }; - } - - const thinking = - options?.thinking ?? - (this._thinkingEffort !== undefined ? { effort: this._thinkingEffort } : undefined); - let explicitThinkingEffort: ThinkingEffort | undefined; - if (thinking !== undefined) { - const hooked = this._hooks?.withThinking?.(thinking.effort, { keep: thinking.keep }, kwargs); - if (hooked !== undefined) { - kwargs = { ...kwargs, ...hooked }; - } else { - explicitThinkingEffort = thinking.effort; - } - } - - let reasoningEffort: string | undefined = - explicitThinkingEffort === 'off' - ? this._offEffort - : explicitThinkingEffort === undefined || explicitThinkingEffort === 'on' - ? undefined - : explicitThinkingEffort; - - if ( - reasoningEffort === undefined && - explicitThinkingEffort !== 'off' && - kwargs['reasoning_effort'] === undefined && - this._hooks?.withThinking === undefined - ) { - const hasThinkPart = history.some((message) => - message.content.some((part) => part.type === 'think'), - ); - if (hasThinkPart) { - reasoningEffort = 'medium'; - } - } - - if (options?.maxCompletionTokens !== undefined) { - let cap = options.maxCompletionTokens; - if ( - options.usedContextTokens !== undefined && - options.maxContextTokens !== undefined && - options.maxContextTokens > 0 - ) { - cap = Math.min(cap, options.maxContextTokens - options.usedContextTokens); - } - cap = Math.max(1, cap); - const hooked = this._hooks?.withMaxCompletionTokens?.(cap); - if (hooked !== undefined) { - kwargs = { ...kwargs, ...hooked }; - } else { - const capped = Math.min(cap, CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING); - kwargs = { ...kwargs, ...completionTokenKwargs(this._model, Math.max(1, capped)) }; - } - } - - for (const key of Object.keys(kwargs)) { - if (kwargs[key] === undefined) { - delete kwargs[key]; - } - } - - return { kwargs, reasoningEffort }; - } - - private _createClient(auth: ProviderRequestAuth | undefined): OpenAI { - return resolveAuthBackedClient( - { cachedClient: this._client, clientFactory: this._clientFactory }, - auth, - (a) => - this._buildClient(requireProviderApiKey('OpenAILegacyChatProvider', a, this._apiKey), a), - ); - } - - private _buildClient(apiKey: string, auth?: ProviderRequestAuth): OpenAI { - const clientOpts: Record = { - apiKey, - baseURL: this._baseUrl, - maxRetries: 0, - }; - const defaultHeaders = mergeRequestHeaders(this._defaultHeaders, auth?.headers); - if (defaultHeaders !== undefined) { - clientOpts['defaultHeaders'] = defaultHeaders; - } - if (this._httpClient !== undefined) { - clientOpts['httpClient'] = this._httpClient; - } - return new OpenAI(clientOpts as ConstructorParameters[0]); - } -} - -export function getOpenAILegacyModelCapability(modelName: string) { - const normalized = modelName.toLowerCase(); - if (isOpenAIReasoningModel(normalized)) { - return OPENAI_REASONING_CAPABILITY; - } - if (hasModelPrefix(normalized, OPENAI_VISION_TOOL_PREFIXES)) { - return OPENAI_VISION_TOOL_CAPABILITY; - } - if (normalized.startsWith('gpt-3.5-turbo')) { - return OPENAI_TEXT_TOOL_CAPABILITY; - } - return undefined; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.contrib.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.contrib.ts deleted file mode 100644 index 6a12e1802..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.contrib.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { registerProtocolBase } from '#/kosong/protocol/protocolBase'; -import { traitConvertError, traitDefaultHeaders } from '#/kosong/protocol/protocolTrait'; - -import { getOpenAIResponsesModelCapability, OpenAIResponsesChatProvider } from './openai-responses'; -import { compactObject, firstProcessEnv, traitEndpoint, traitProvides } from './openaiHooks'; - -registerProtocolBase({ - id: 'openai_responses', - capability: getOpenAIResponsesModelCapability, - createChatProvider({ config, traits }) { - const endpoint = traitEndpoint(traits); - return new OpenAIResponsesChatProvider({ - ...(traitProvides(traits) as Partial< - ConstructorParameters[0] - >), - model: config.modelName, - ...compactObject({ - apiKey: - config.apiKey ?? - firstProcessEnv(endpoint?.apiKeyEnv) ?? - (endpoint === undefined ? undefined : ''), - baseUrl: - config.baseUrl ?? firstProcessEnv(endpoint?.baseUrlEnv) ?? endpoint?.defaultBaseUrl, - defaultHeaders: traitDefaultHeaders(traits), - maxOutputTokens: config.providerOptions?.defaultMaxTokens, - offEffort: config.providerOptions?.offEffort, - convertError: traitConvertError(traits), - }), - }); - }, -}); diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts deleted file mode 100644 index 27deea941..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts +++ /dev/null @@ -1,1239 +0,0 @@ -import OpenAI from 'openai'; - -import { Error2 } from '#/_base/errors/errors'; -import { - APIContextOverflowError, - APIProviderQuotaExhaustedError, - APIProviderRateLimitError, - ChatProviderError, - isContextOverflowErrorCode, -} from '#/kosong/contract/errors'; -import type { - ContentPart, - Message, - StreamedMessagePart, - ToolCall, -} from '#/kosong/contract/message'; -import { extractText, isToolDeclarationOnlyMessage } from '#/kosong/contract/message'; -import type { - ChatProvider, - FinishReason, - GenerateOptions, - ProviderRequestAuth, - ResponseFormat, - StreamedMessage, - ThinkingEffort, - ToolCallIdPolicy, -} from '#/kosong/contract/provider'; -import type { Tool } from '#/kosong/contract/tool'; -import type { TokenUsage } from '#/kosong/contract/usage'; -import { ProtocolErrors } from '#/kosong/protocol/errors'; - -import { - convertOpenAIError, - hasModelPrefix, - isMediaPart, - isOpenAIInsufficientQuotaCode, - isOpenAIReasoningModel, - OPENAI_REASONING_CAPABILITY, - OPENAI_VISION_TOOL_CAPABILITY, - OPENAI_VISION_TOOL_PREFIXES, - TOOL_RESULT_MEDIA_PLACEHOLDER, - TOOL_RESULT_MEDIA_PROMPT, - type ToolMessageConversion, -} from './openai-common'; -import { - mergeRequestHeaders, - requireProviderApiKey, - resolveAuthBackedClient, -} from '../request-auth'; -import { normalizeToolCallIdsForProvider, sanitizeOpenAIResponsesCallId } from '../tool-call-id'; - -function normalizeResponsesFinishReason( - status: string | null | undefined, - incompleteReason: string | null | undefined, -): { finishReason: FinishReason | null; rawFinishReason: string | null } { - if (status === null || status === undefined) { - return { finishReason: null, rawFinishReason: null }; - } - if (status === 'completed') { - return { finishReason: 'completed', rawFinishReason: 'completed' }; - } - if (status === 'incomplete') { - if (incompleteReason === 'max_output_tokens') { - return { finishReason: 'truncated', rawFinishReason: 'max_output_tokens' }; - } - if (incompleteReason === 'content_filter') { - return { finishReason: 'filtered', rawFinishReason: 'content_filter' }; - } - return { - finishReason: 'other', - rawFinishReason: incompleteReason ?? 'incomplete', - }; - } - if (status === 'failed') { - return { finishReason: 'other', rawFinishReason: 'failed' }; - } - return { finishReason: null, rawFinishReason: null }; -} - -type RawObject = Record; -const OPENAI_RESPONSES_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { - normalize: (id) => sanitizeOpenAIResponsesCallId(id, 64), - maxLength: 64, -}; - -type ResponseOutputItemView = - | { - type: 'message'; - content: RawObject[]; - } - | { - type: 'function_call'; - itemId?: string; - callId?: string; - name?: string; - arguments?: string | null; - } - | { - type: 'reasoning'; - encryptedContent?: string; - summary: RawObject[]; - } - | { - type: 'other'; - }; - -function asRawObject(value: unknown): RawObject | null { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return null; - } - return value as RawObject; -} - -function readStringField(object: RawObject, key: string): string | undefined { - const value = object[key]; - return typeof value === 'string' ? value : undefined; -} - -function hasOwn(object: RawObject, key: string): boolean { - return Object.prototype.hasOwnProperty.call(object, key); -} - -function readNullableStringField(object: RawObject, key: string): string | null | undefined { - const value = object[key]; - if (value === null) return null; - return typeof value === 'string' ? value : undefined; -} - -function readNumberField(object: RawObject, key: string): number | undefined { - const value = object[key]; - return typeof value === 'number' ? value : undefined; -} - -function readObjectField(object: RawObject, key: string): RawObject | undefined { - return asRawObject(object[key]) ?? undefined; -} - -function readObjectArrayField(object: RawObject, key: string): RawObject[] | undefined { - const value = object[key]; - if (!Array.isArray(value)) return undefined; - return value.flatMap((item) => { - const objectItem = asRawObject(item); - return objectItem === null ? [] : [objectItem]; - }); -} - -function failResponsesDecode(context: string, detail: string): never { - throw new ChatProviderError(`OpenAI Responses decode error: ${context} ${detail}`); -} - -function requireStringField(object: RawObject, key: string, context: string): string { - const value = readStringField(object, key); - if (value === undefined) { - failResponsesDecode(`${context}.${key}`, 'must be a string.'); - } - return value; -} - -function requireObjectField(object: RawObject, key: string, context: string): RawObject { - const value = readObjectField(object, key); - if (value === undefined) { - failResponsesDecode(`${context}.${key}`, 'must be an object.'); - } - return value; -} - -function readResponseOutputItem(value: unknown, context: string): ResponseOutputItemView { - const item = asRawObject(value); - if (item === null) { - failResponsesDecode(context, 'must be an object.'); - } - - const type = requireStringField(item, 'type', context); - - if (type === 'message') { - return { - type, - content: readObjectArrayField(item, 'content') ?? [], - }; - } - - if (type === 'function_call') { - return { - type, - itemId: readStringField(item, 'id'), - callId: readStringField(item, 'call_id'), - name: readStringField(item, 'name'), - arguments: readNullableStringField(item, 'arguments'), - }; - } - - if (type === 'reasoning') { - return { - type, - encryptedContent: readStringField(item, 'encrypted_content'), - summary: readObjectArrayField(item, 'summary') ?? [], - }; - } - - return { type: 'other' }; -} - -function responseStreamIndex( - itemId: string | undefined, - outputIndex: number | undefined, -): string | number | undefined { - return itemId ?? outputIndex; -} - -function formatResponseStreamIndex(streamIndex: string | number | undefined): string { - return streamIndex === undefined ? '' : String(streamIndex); -} - -function requireFunctionCallName(item: { name?: string }): string { - if (item.name === undefined) { - throw new ChatProviderError('OpenAI Responses function_call item is missing a name.'); - } - return item.name; -} - -function functionCallId(callId: string | undefined): string { - return callId === undefined || callId.length === 0 ? crypto.randomUUID() : callId; -} - -function formatResponsesErrorEvent( - code: string | null, - message: string, - param: string | null, -): string { - const codeText = code ?? 'unknown'; - const paramText = param === null ? '' : ` (param: ${param})`; - return `${codeText}: ${message}${paramText}`; -} - -const EMBEDDED_STATUS_CODE_RE = /\bstatus_code\s*[:=]\s*(\d{3})\b/; - -function readEmbeddedStatusCode(message: string): number | undefined { - const match = EMBEDDED_STATUS_CODE_RE.exec(message); - return match === null ? undefined : Number(match[1]); -} - -function errorFromOpenAIResponsesEvent( - prefix: string, - code: string | null, - message: string, - param: string | null, - options?: { - readonly rawEvent?: unknown; - readonly convertErrorHook?: (error: unknown) => ChatProviderError | undefined; - }, -): ChatProviderError { - const formatted = formatResponsesErrorEvent(code, message, param); - const fullMessage = `${prefix}: ${formatted}`; - const hooked = options?.convertErrorHook?.(options.rawEvent ?? { code, message, param }); - if (hooked !== undefined) { - return hooked; - } - if (isContextOverflowErrorCode(code)) { - return new APIContextOverflowError(400, fullMessage); - } - if (isOpenAIInsufficientQuotaCode(code)) { - return new APIProviderQuotaExhaustedError(fullMessage); - } - if (code === 'rate_limit_exceeded' || readEmbeddedStatusCode(message) === 429) { - return new APIProviderRateLimitError(fullMessage); - } - return new ChatProviderError(fullMessage); -} - -function parseNestedGatewayStreamError(message: string): - | { - code: string | null; - message: string; - param: string | null; - } - | undefined { - const marker = 'received error while streaming:'; - const markerIndex = message.indexOf(marker); - if (markerIndex === -1) return undefined; - - const jsonText = message.slice(markerIndex + marker.length).trim(); - if (jsonText.length === 0) return undefined; - - let parsed: unknown; - try { - parsed = JSON.parse(jsonText); - } catch { - return undefined; - } - - const error = asRawObject(parsed); - if (error === null) return undefined; - - const nestedMessage = readStringField(error, 'message'); - if (nestedMessage === undefined) return undefined; - - return { - code: readNullableStringField(error, 'code') ?? null, - message: nestedMessage, - param: readNullableStringField(error, 'param') ?? null, - }; -} - -function malformedStreamErrorEvent( - message: string, - convertErrorHook?: (error: unknown) => ChatProviderError | undefined, -): ChatProviderError { - const nested = parseNestedGatewayStreamError(message); - if (nested !== undefined) { - return errorFromOpenAIResponsesEvent( - 'OpenAI Responses malformed stream error', - nested.code, - nested.message, - nested.param, - { convertErrorHook }, - ); - } - - return errorFromOpenAIResponsesEvent( - 'OpenAI Responses malformed stream error', - null, - message, - null, - { convertErrorHook }, - ); -} - -function readResponsesFailedResponseError(response: RawObject): - | { - code: string | null; - message: string; - } - | undefined { - const error = readObjectField(response, 'error'); - if (error !== undefined) { - const code = readNullableStringField(error, 'code') ?? 'unknown'; - const message = readStringField(error, 'message') ?? 'no message'; - return { code, message }; - } - return undefined; -} - -function formatResponsesFailedResponse(response: RawObject): string { - const error = readResponsesFailedResponseError(response); - if (error !== undefined) { - return formatResponsesErrorEvent(error.code, error.message, null); - } - - const incompleteDetails = readObjectField(response, 'incomplete_details'); - const reason = - incompleteDetails === undefined ? undefined : readStringField(incompleteDetails, 'reason'); - return reason === undefined - ? 'Unknown error (no error details in response)' - : `incomplete: ${reason}`; -} - -export interface OpenAIResponsesOptions { - apiKey?: string | undefined; - baseUrl?: string | undefined; - model: string; - maxOutputTokens?: number | undefined; - offEffort?: string | undefined; - thinkingEffort?: ThinkingEffort | undefined; - httpClient?: unknown; - defaultHeaders?: Record; - toolMessageConversion?: ToolMessageConversion | undefined; - clientFactory?: (auth: ProviderRequestAuth) => OpenAI; - convertError?: (error: unknown) => ChatProviderError | undefined; -} - -export interface OpenAIResponsesGenerationKwargs { - max_output_tokens?: number | undefined; - temperature?: number | undefined; - top_p?: number | undefined; - reasoning_effort?: string | undefined; - [key: string]: unknown; -} - -interface ResponseInputItem { - [key: string]: unknown; -} - -interface ResponseToolParam { - type: string; - name: string; - description: string; - parameters: Record; - strict: boolean; -} - -function responseFormatToResponsesText(format: ResponseFormat): Record { - if (format.type === 'json_object') { - return { format: { type: 'json_object' } }; - } - return { - format: { - type: 'json_schema', - name: format.jsonSchema.name, - schema: format.jsonSchema.schema, - strict: format.jsonSchema.strict, - description: format.jsonSchema.description, - }, - }; -} - -const OMITTED_AUDIO_PLACEHOLDER = '(audio omitted: unsupported audio format)'; -const OMITTED_VIDEO_PLACEHOLDER = '(video omitted: not supported by this provider)'; - -function contentPartsToInputItems(parts: ContentPart[]): unknown[] { - const items: unknown[] = []; - for (const part of parts) { - switch (part.type) { - case 'text': - if (part.text) { - items.push({ type: 'input_text', text: part.text }); - } - break; - case 'image_url': - items.push({ - type: 'input_image', - detail: 'auto', - image_url: part.imageUrl.url, - }); - break; - case 'audio_url': { - const mapped = mapAudioUrlToInputItem(part.audioUrl.url); - items.push(mapped ?? { type: 'input_text', text: OMITTED_AUDIO_PLACEHOLDER }); - break; - } - case 'video_url': - items.push({ type: 'input_text', text: OMITTED_VIDEO_PLACEHOLDER }); - break; - case 'think': - break; - } - } - return items; -} - -function contentPartsToOutputItems(parts: ContentPart[]): unknown[] { - const items: unknown[] = []; - for (const part of parts) { - if (part.type === 'text' && part.text) { - items.push({ type: 'output_text', text: part.text, annotations: [] }); - } - } - return items; -} - -function messageContentToFunctionOutputItems(content: ContentPart[]): unknown[] { - const items: unknown[] = []; - for (const part of content) { - switch (part.type) { - case 'text': - if (part.text) { - items.push({ type: 'input_text', text: part.text }); - } - break; - case 'image_url': - items.push({ type: 'input_image', image_url: part.imageUrl.url }); - break; - case 'audio_url': { - const mapped = mapAudioUrlToInputItem(part.audioUrl.url); - items.push(mapped ?? { type: 'input_text', text: OMITTED_AUDIO_PLACEHOLDER }); - break; - } - case 'video_url': - items.push({ type: 'input_text', text: OMITTED_VIDEO_PLACEHOLDER }); - break; - case 'think': - break; - } - } - return items; -} - -function mapAudioUrlToInputItem(url: string): unknown { - if (url.startsWith('data:audio/')) { - try { - const parts = url.split(',', 2); - if (parts.length !== 2 || parts[0] === undefined || parts[1] === undefined) return null; - const header = parts[0]; - const b64 = parts[1]; - const subtypePart = header.split('/')[1]; - if (subtypePart === undefined) return null; - const [subtypeHead = ''] = subtypePart.split(';'); - const subtype = subtypeHead.toLowerCase(); - const ext = - subtype === 'mp3' || subtype === 'mpeg' ? 'mp3' : subtype === 'wav' ? 'wav' : null; - if (ext === null) return null; - return { type: 'input_file', file_data: b64, filename: `inline.${ext}` }; - } catch { - return null; - } - } - if (url.startsWith('http://') || url.startsWith('https://')) { - return { type: 'input_file', file_url: url }; - } - return null; -} - -export function rejectsMaxOutputTokens(baseUrl: string | undefined): boolean { - if (baseUrl === undefined) return false; - let host: string; - let path: string; - try { - const parsed = new URL(baseUrl); - host = parsed.hostname.toLowerCase(); - path = parsed.pathname.toLowerCase(); - } catch { - return false; - } - return host === 'chatgpt.com' && path.startsWith('/backend-api/codex'); -} - -const OPENAI_RESPONSES_DEVELOPER_ROLE_MODELS = new Set([ - 'gpt-4.1', - 'gpt-4.1-mini', - 'gpt-4.1-nano', - 'gpt-5-codex', - 'o1', - 'o1-mini', - 'o1-pro', - 'o3', - 'o3-mini', - 'o3-pro', - 'o4-mini', -]); - -export function usesOpenAIResponsesDeveloperRole(modelName: string): boolean { - const normalized = modelName.toLowerCase(); - if (OPENAI_RESPONSES_DEVELOPER_ROLE_MODELS.has(normalized)) return true; - for (const cataloguedModel of OPENAI_RESPONSES_DEVELOPER_ROLE_MODELS) { - if (normalized.startsWith(cataloguedModel + '-')) return true; - } - return false; -} - -function convertMessage( - message: Message, - modelName: string, - toolMessageConversion: ToolMessageConversion, -): ResponseInputItem[] { - let role: string = message.role; - if (usesOpenAIResponsesDeveloperRole(modelName) && role === 'system') { - role = 'developer'; - } - - if (role === 'tool') { - const callId = message.toolCallId ?? ''; - let output: string | unknown[]; - if (toolMessageConversion === 'extract_text') { - const text = extractText(message); - output = - text.length === 0 && message.content.some(isMediaPart) - ? TOOL_RESULT_MEDIA_PLACEHOLDER - : text; - } else { - output = messageContentToFunctionOutputItems(message.content); - } - return [ - { - call_id: callId, - output, - type: 'function_call_output', - }, - ]; - } - - const result: ResponseInputItem[] = []; - - if (message.content.length > 0) { - const pendingParts: ContentPart[] = []; - - const flushPendingParts = (): void => { - if (pendingParts.length === 0) return; - if (role === 'assistant') { - result.push({ - content: contentPartsToOutputItems(pendingParts), - role, - type: 'message', - }); - } else { - result.push({ - content: contentPartsToInputItems(pendingParts), - role, - type: 'message', - }); - } - pendingParts.length = 0; - }; - - let i = 0; - const n = message.content.length; - while (i < n) { - const part = message.content[i]; - if (part === undefined) break; - if (part.type === 'think') { - flushPendingParts(); - const encryptedValue = part.encrypted; - const summaries: unknown[] = [{ type: 'summary_text', text: part.think }]; - i += 1; - while (i < n) { - const nextPart = message.content[i]; - if (nextPart === undefined) break; - if (nextPart.type !== 'think') break; - if (nextPart.encrypted !== encryptedValue) break; - summaries.push({ type: 'summary_text', text: nextPart.think }); - i += 1; - } - result.push({ - summary: summaries, - type: 'reasoning', - encrypted_content: encryptedValue, - }); - } else { - pendingParts.push(part); - i += 1; - } - } - - flushPendingParts(); - } - - for (const toolCall of message.toolCalls) { - result.push({ - arguments: toolCall.arguments ?? '{}', - call_id: toolCall.id, - name: toolCall.name, - type: 'function_call', - }); - } - - return result; -} - -function convertTool(tool: Tool): ResponseToolParam { - return { - type: 'function', - name: tool.name, - description: tool.description, - parameters: tool.parameters, - strict: false, - }; -} - -function convertHistoryMessages( - history: readonly Message[], - modelName: string, - toolMessageConversion: ToolMessageConversion, -): unknown[] { - const input: unknown[] = []; - const pendingToolResultMedia: unknown[] = []; - - const flushPendingMedia = (): void => { - if (pendingToolResultMedia.length === 0) return; - input.push({ - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: TOOL_RESULT_MEDIA_PROMPT }, ...pendingToolResultMedia], - }); - pendingToolResultMedia.length = 0; - }; - - for (const msg of history) { - if (isToolDeclarationOnlyMessage(msg)) continue; - if (msg.role !== 'tool') { - flushPendingMedia(); - } - input.push(...convertMessage(msg, modelName, toolMessageConversion)); - if (msg.role === 'tool' && toolMessageConversion === 'extract_text') { - pendingToolResultMedia.push( - ...messageContentToFunctionOutputItems(msg.content.filter(isMediaPart)), - ); - } - } - - flushPendingMedia(); - return input; -} - -export class OpenAIResponsesStreamedMessage implements StreamedMessage { - private _id: string | null = null; - private _usage: TokenUsage | null = null; - private _finishReason: FinishReason | null = null; - private _rawFinishReason: string | null = null; - private readonly _iter: AsyncGenerator; - - constructor( - response: unknown, - isStream: boolean, - private readonly _convertErrorHook?: - | ((error: unknown) => ChatProviderError | undefined) - | undefined, - ) { - if (isStream) { - this._iter = this._convertStreamResponse(response as AsyncIterable); - } else { - this._iter = this._convertNonStreamResponse(response as RawObject); - } - } - - get id(): string | null { - return this._id; - } - - get usage(): TokenUsage | null { - return this._usage; - } - - get finishReason(): FinishReason | null { - return this._finishReason; - } - - get rawFinishReason(): string | null { - return this._rawFinishReason; - } - - async *[Symbol.asyncIterator](): AsyncIterator { - yield* this._iter; - } - - private _captureFinishReasonFromResponse(response: RawObject): void { - const status = readNullableStringField(response, 'status'); - const incomplete = readObjectField(response, 'incomplete_details'); - const incompleteReason = incomplete ? readStringField(incomplete, 'reason') : null; - const normalized = normalizeResponsesFinishReason(status, incompleteReason); - this._finishReason = normalized.finishReason; - this._rawFinishReason = normalized.rawFinishReason; - } - - private _extractUsage(usage: RawObject): void { - const inputTokens = readNumberField(usage, 'input_tokens') ?? 0; - const outputTokens = readNumberField(usage, 'output_tokens') ?? 0; - const details = readObjectField(usage, 'input_tokens_details'); - const cached = details ? (readNumberField(details, 'cached_tokens') ?? 0) : 0; - this._usage = { - inputOther: inputTokens - cached, - output: outputTokens, - inputCacheRead: cached, - inputCacheCreation: 0, - }; - } - - private async *_convertNonStreamResponse( - response: RawObject, - ): AsyncGenerator { - this._id = readStringField(response, 'id') ?? null; - const usage = readObjectField(response, 'usage'); - if (usage !== undefined) { - this._extractUsage(usage); - } - this._captureFinishReasonFromResponse(response); - - const output = readObjectArrayField(response, 'output'); - if (output === undefined) return; - - for (const item of output) { - const outputItem = readResponseOutputItem(item, 'response.output item'); - - if (outputItem.type === 'message') { - for (const contentItem of outputItem.content) { - if (contentItem['type'] === 'output_text') { - const text = readStringField(contentItem, 'text'); - if (text !== undefined) { - yield { type: 'text', text }; - } - } - } - } else if (outputItem.type === 'function_call') { - yield { - type: 'function', - id: functionCallId(outputItem.callId), - name: requireFunctionCallName(outputItem), - arguments: outputItem.arguments ?? null, - } satisfies ToolCall; - } else if (outputItem.type === 'reasoning') { - let hasReasoningSummary = false; - for (const summary of outputItem.summary) { - const text = readStringField(summary, 'text'); - if (text === undefined) continue; - hasReasoningSummary = true; - const thinkPart: StreamedMessagePart = { - type: 'think', - think: text, - }; - if (outputItem.encryptedContent !== undefined) { - (thinkPart as { encrypted: string }).encrypted = outputItem.encryptedContent; - } - yield thinkPart; - } - if (!hasReasoningSummary) { - const thinkPart: StreamedMessagePart = { type: 'think', think: '' }; - if (outputItem.encryptedContent !== undefined) { - (thinkPart as { encrypted: string }).encrypted = outputItem.encryptedContent; - } - yield thinkPart; - } - } - } - } - - private async *_convertStreamResponse( - response: AsyncIterable, - ): AsyncGenerator { - const functionCallArgumentsByIndex = new Map(); - let unindexedFunctionCallArguments: string | undefined; - - const hasFunctionCallArguments = (streamIndex: number | string | undefined): boolean => - streamIndex === undefined - ? unindexedFunctionCallArguments !== undefined - : functionCallArgumentsByIndex.has(streamIndex); - - const getFunctionCallArguments = (streamIndex: number | string | undefined): string => - streamIndex === undefined - ? (unindexedFunctionCallArguments as string) - : functionCallArgumentsByIndex.get(streamIndex)!; - - const setFunctionCallArguments = ( - streamIndex: number | string | undefined, - argumentsValue: string, - ): void => { - if (streamIndex === undefined) { - unindexedFunctionCallArguments = argumentsValue; - } else { - functionCallArgumentsByIndex.set(streamIndex, argumentsValue); - } - }; - - const appendFunctionCallArguments = ( - streamIndex: number | string | undefined, - argumentsPart: string, - context: string, - ): void => { - if (!hasFunctionCallArguments(streamIndex)) { - failResponsesDecode( - context, - `received function-call arguments for unknown stream index ${formatResponseStreamIndex(streamIndex)}.`, - ); - } - setFunctionCallArguments(streamIndex, getFunctionCallArguments(streamIndex) + argumentsPart); - }; - - const yieldFinalArgumentsSuffix = function* ( - streamIndex: number | string | undefined, - finalArguments: string, - context: string, - ): Generator { - if (!hasFunctionCallArguments(streamIndex)) { - failResponsesDecode( - context, - `received final function-call arguments for unknown stream index ${formatResponseStreamIndex(streamIndex)}.`, - ); - } - - const accumulatedArguments = getFunctionCallArguments(streamIndex); - if (finalArguments === accumulatedArguments) { - return; - } - - if (!finalArguments.startsWith(accumulatedArguments)) { - throw new ChatProviderError( - `OpenAI Responses final function-call arguments for stream index ${formatResponseStreamIndex( - streamIndex, - )} do not match the streamed argument deltas.`, - ); - } - - const suffix = finalArguments.slice(accumulatedArguments.length); - setFunctionCallArguments(streamIndex, finalArguments); - if (suffix.length === 0) { - return; - } - - const part: StreamedMessagePart = { - type: 'tool_call_part', - argumentsPart: suffix, - }; - if (streamIndex !== undefined) { - (part as { index: number | string }).index = streamIndex; - } - yield part; - }; - - try { - for await (const chunk of response) { - const type = readStringField(chunk, 'type'); - if (type === undefined) { - if (!hasOwn(chunk, 'type')) { - const message = readStringField(chunk, 'message'); - if (message !== undefined) { - throw malformedStreamErrorEvent(message, this._convertErrorHook); - } - } - failResponsesDecode('stream event.type', 'must be a string.'); - } - - switch (type) { - case 'response.output_text.delta': - yield { type: 'text', text: requireStringField(chunk, 'delta', type) }; - break; - case 'response.created': - case 'response.in_progress': { - const responseObject = requireObjectField(chunk, 'response', type); - const respId = readStringField(responseObject, 'id'); - if (respId !== undefined) { - this._id = respId; - } - break; - } - case 'response.output_item.added': { - const item = readResponseOutputItem(chunk['item'], `${type}.item`); - const outputIndex = readNumberField(chunk, 'output_index'); - if (item.type === 'function_call') { - const streamIndex = responseStreamIndex(item.itemId, outputIndex); - setFunctionCallArguments(streamIndex, item.arguments ?? ''); - const tc: ToolCall = { - type: 'function', - id: functionCallId(item.callId), - name: requireFunctionCallName(item), - arguments: item.arguments ?? null, - }; - if (streamIndex !== undefined) { - tc._streamIndex = streamIndex; - } - yield tc; - } - break; - } - case 'response.output_item.done': { - const item = readResponseOutputItem(chunk['item'], `${type}.item`); - const outputIndex = readNumberField(chunk, 'output_index'); - if (item.type === 'reasoning') { - const thinkPart: StreamedMessagePart = { type: 'think', think: '' }; - if (item.encryptedContent !== undefined) { - (thinkPart as { encrypted: string }).encrypted = item.encryptedContent; - } - yield thinkPart; - } else if (item.type === 'function_call' && typeof item.arguments === 'string') { - const streamIndex = responseStreamIndex(item.itemId, outputIndex); - yield* yieldFinalArgumentsSuffix(streamIndex, item.arguments, type); - } - break; - } - case 'response.function_call_arguments.delta': { - const streamIndex = responseStreamIndex( - readStringField(chunk, 'item_id'), - readNumberField(chunk, 'output_index'), - ); - const argumentsPart = requireStringField(chunk, 'delta', type); - const part: StreamedMessagePart = { - type: 'tool_call_part', - argumentsPart, - }; - appendFunctionCallArguments(streamIndex, argumentsPart, type); - if (streamIndex !== undefined) { - (part as { index: number | string }).index = streamIndex; - } - yield part; - break; - } - case 'response.function_call_arguments.done': { - const functionArguments = requireStringField(chunk, 'arguments', type); - const streamIndex = responseStreamIndex( - readStringField(chunk, 'item_id'), - readNumberField(chunk, 'output_index'), - ); - yield* yieldFinalArgumentsSuffix(streamIndex, functionArguments, type); - break; - } - case 'response.reasoning_summary_part.added': - yield { type: 'think', think: '' }; - break; - case 'response.reasoning_summary_text.delta': - yield { type: 'think', think: requireStringField(chunk, 'delta', type) }; - break; - case 'response.completed': - case 'response.incomplete': { - const responseObject = requireObjectField(chunk, 'response', type); - const respId = readStringField(responseObject, 'id'); - if (respId !== undefined) { - this._id = respId; - } - const usage = readObjectField(responseObject, 'usage'); - if (usage !== undefined) { - this._extractUsage(usage); - } - this._captureFinishReasonFromResponse(responseObject); - break; - } - case 'error': { - const message = requireStringField(chunk, 'message', type); - throw errorFromOpenAIResponsesEvent( - 'OpenAI Responses stream error', - readNullableStringField(chunk, 'code') ?? null, - message, - readNullableStringField(chunk, 'param') ?? null, - { rawEvent: chunk, convertErrorHook: this._convertErrorHook }, - ); - } - case 'response.failed': { - const responseObject = requireObjectField(chunk, 'response', type); - const error = readResponsesFailedResponseError(responseObject); - if (error !== undefined) { - throw errorFromOpenAIResponsesEvent( - 'OpenAI Responses response.failed', - error.code, - error.message, - null, - { rawEvent: chunk, convertErrorHook: this._convertErrorHook }, - ); - } - throw new ChatProviderError( - `OpenAI Responses response.failed: ${formatResponsesFailedResponse(responseObject)}`, - ); - } - default: - break; - } - } - } catch (error: unknown) { - throw convertOpenAIError(error, this._convertErrorHook); - } - } -} - -export class OpenAIResponsesChatProvider implements ChatProvider { - readonly name: string = 'openai-responses'; - - private readonly _model: string; - private readonly _stream: boolean; - private readonly _apiKey: string | undefined; - private readonly _baseUrl: string | undefined; - private readonly _defaultHeaders: Record | undefined; - private readonly _thinkingEffort: ThinkingEffort | undefined; - private readonly _offEffort: string | undefined; - private readonly _generationKwargs: OpenAIResponsesGenerationKwargs; - private readonly _toolMessageConversion: ToolMessageConversion; - private readonly _client: OpenAI | undefined; - private readonly _httpClient: unknown; - private readonly _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined; - private readonly _convertErrorHook: ((error: unknown) => ChatProviderError | undefined) | undefined; - - constructor(options: OpenAIResponsesOptions) { - const apiKey = options.apiKey ?? process.env['OPENAI_API_KEY']; - this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey; - this._baseUrl = options.baseUrl ?? 'https://api.openai.com/v1'; - this._defaultHeaders = options.defaultHeaders; - this._model = options.model; - this._stream = true; - this._thinkingEffort = options.thinkingEffort; - this._offEffort = options.offEffort; - this._generationKwargs = {}; - this._toolMessageConversion = options.toolMessageConversion ?? null; - this._httpClient = options.httpClient; - this._clientFactory = options.clientFactory; - this._convertErrorHook = options.convertError; - - if (options.maxOutputTokens !== undefined) { - this._generationKwargs.max_output_tokens = options.maxOutputTokens; - } - - this._client = this._apiKey === undefined ? undefined : this._buildClient(this._apiKey); - } - - get modelName(): string { - return this._model; - } - - get thinkingEffort(): ThinkingEffort | null { - return this._thinkingEffort ?? null; - } - - get maxCompletionTokens(): number | undefined { - return this._generationKwargs.max_output_tokens; - } - - async generate( - systemPrompt: string, - tools: Tool[], - history: Message[], - options?: GenerateOptions, - ): Promise { - const input: unknown[] = []; - - const normalizedHistory = normalizeToolCallIdsForProvider( - history, - OPENAI_RESPONSES_TOOL_CALL_ID_POLICY, - ); - input.push( - ...convertHistoryMessages(normalizedHistory, this._model, this._toolMessageConversion), - ); - - let kwargs: Record = { ...this._generationKwargs }; - - if (options?.cacheKey !== undefined) { - kwargs = { ...kwargs, prompt_cache_key: options.cacheKey }; - } - if (options?.sampling?.temperature !== undefined) { - kwargs = { ...kwargs, temperature: options.sampling.temperature }; - } - if (options?.sampling?.topP !== undefined) { - kwargs = { ...kwargs, top_p: options.sampling.topP }; - } - - const thinking = - options?.thinking ?? - (this._thinkingEffort !== undefined ? { effort: this._thinkingEffort } : undefined); - if (thinking !== undefined) { - const effort = - thinking.effort === 'off' - ? this._offEffort - : thinking.effort === 'on' - ? undefined - : thinking.effort; - kwargs = { ...kwargs, reasoning_effort: effort }; - } - - if (options?.maxCompletionTokens !== undefined) { - let cap = options.maxCompletionTokens; - if ( - options.usedContextTokens !== undefined && - options.maxContextTokens !== undefined && - options.maxContextTokens > 0 - ) { - cap = Math.min(cap, options.maxContextTokens - options.usedContextTokens); - } - kwargs = { ...kwargs, max_output_tokens: Math.max(1, cap) }; - } - - const reasoningEffort = kwargs['reasoning_effort'] as string | undefined; - delete kwargs['reasoning_effort']; - - if (reasoningEffort !== undefined) { - kwargs['reasoning'] = { - effort: reasoningEffort, - summary: 'auto', - }; - kwargs['include'] = ['reasoning.encrypted_content']; - } - - if (rejectsMaxOutputTokens(this._baseUrl)) { - delete kwargs['max_output_tokens']; - } - - for (const key of Object.keys(kwargs)) { - if (kwargs[key] === undefined) { - delete kwargs[key]; - } - } - - try { - const client = this._createClient(options?.auth); - const createParams: Record = { - model: this._model, - input, - tools: tools.map((t) => convertTool(t)), - store: false, - stream: this._stream, - ...kwargs, - }; - if (systemPrompt) { - createParams['instructions'] = systemPrompt; - } - if (options?.responseFormat !== undefined) { - createParams['text'] = { - ...asRawObject(createParams['text']), - ...responseFormatToResponsesText(options.responseFormat), - }; - } - - if ( - !('responses' in client) || - typeof (client as { responses?: { create?: unknown } }).responses?.create !== 'function' - ) { - throw new Error2( - ProtocolErrors.codes.PROVIDER_API_ERROR, - 'OpenAI SDK version does not support Responses API. Upgrade to >=4.x with responses support.', - ); - } - - options?.onRequestSent?.(); - const requestOptions: Record = {}; - if (options?.signal) { - requestOptions['signal'] = options.signal; - } - if (options?.extraHeaders !== undefined) { - requestOptions['headers'] = options.extraHeaders; - } - const response = await ( - client.responses as { - create(params: unknown, opts?: unknown): Promise; - } - ).create( - createParams, - Object.keys(requestOptions).length > 0 ? requestOptions : undefined, - ); - return new OpenAIResponsesStreamedMessage(response, this._stream, this._convertErrorHook); - } catch (error: unknown) { - throw convertOpenAIError(error, this._convertErrorHook); - } - } - - private _createClient(auth: ProviderRequestAuth | undefined): OpenAI { - return resolveAuthBackedClient( - { cachedClient: this._client, clientFactory: this._clientFactory }, - auth, - (a) => - this._buildClient(requireProviderApiKey('OpenAIResponsesChatProvider', a, this._apiKey), a), - ); - } - - private _buildClient(apiKey: string, auth?: ProviderRequestAuth): OpenAI { - const clientOpts: Record = { - apiKey, - baseURL: this._baseUrl, - maxRetries: 0, - }; - const defaultHeaders = mergeRequestHeaders(this._defaultHeaders, auth?.headers); - if (defaultHeaders !== undefined) { - clientOpts['defaultHeaders'] = defaultHeaders; - } - if (this._httpClient !== undefined) { - clientOpts['httpClient'] = this._httpClient; - } - return new OpenAI(clientOpts as ConstructorParameters[0]); - } -} - -export function getOpenAIResponsesModelCapability(modelName: string) { - const normalized = modelName.toLowerCase(); - if (isOpenAIReasoningModel(normalized)) { - return OPENAI_REASONING_CAPABILITY; - } - if (hasModelPrefix(normalized, OPENAI_VISION_TOOL_PREFIXES)) { - return OPENAI_VISION_TOOL_CAPABILITY; - } - return undefined; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openaiHooks.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openaiHooks.ts deleted file mode 100644 index a1a512491..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openaiHooks.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { GenerateOptions, VideoUploadInput } from '#/kosong/contract/provider'; -import type { Tool } from '#/kosong/contract/tool'; -import type { ProtocolEndpoint, ResolvedTrait } from '#/kosong/protocol/protocolTrait'; - -import type { OpenAIChatCompletionsHooks } from './openai-legacy'; - -export function composeOpenAIChatHooks( - traits: readonly ResolvedTrait[], -): OpenAIChatCompletionsHooks | undefined { - const hooks: OpenAIChatCompletionsHooks = {}; - - const messageShapers = traits.filter(({ trait }) => trait.convertMessage !== undefined); - if (messageShapers.length > 0) { - hooks.convertMessage = (message, converted) => { - let current: Record | null = converted; - for (const { trait, context } of messageShapers) { - current = trait.convertMessage!(message, current, context); - if (current === null) return null; - } - return current; - }; - } - - const historyMergers = traits.filter(({ trait }) => trait.mergeHistory !== undefined); - if (historyMergers.length > 0) { - hooks.mergeHistory = (messages) => { - let current: readonly Record[] = messages; - for (const { trait, context } of historyMergers) { - const next = trait.mergeHistory!(current, context); - if (next !== undefined) current = next; - } - return [...current]; - }; - } - - const paramsBuilders = traits.filter(({ trait }) => trait.buildParams !== undefined); - if (paramsBuilders.length > 0) { - hooks.buildParams = (params) => { - let current = params; - for (const { trait, context } of paramsBuilders) { - const next = trait.buildParams!(current, context); - if (next !== undefined) current = next; - } - return current; - }; - } - - for (const { trait, context } of traits) { - if (trait.convertTool !== undefined) { - hooks.convertTool = (tool: Tool) => trait.convertTool!(tool, context); - } - if (trait.convertError !== undefined) { - hooks.convertError = (error: unknown) => trait.convertError!(error, context); - } - if (trait.toolCallIdPolicy !== undefined) { - hooks.toolCallIdPolicy = () => trait.toolCallIdPolicy!(context); - } - if (trait.withThinking !== undefined) { - hooks.withThinking = (effort, options, generationKwargs) => - trait.withThinking!(effort, options, generationKwargs, context); - } - if (trait.preserveThinking !== undefined) { - hooks.preserveThinking = (generationKwargs) => - trait.preserveThinking!(generationKwargs, context); - } - if (trait.withMaxCompletionTokens !== undefined) { - hooks.withMaxCompletionTokens = (maxCompletionTokens) => - trait.withMaxCompletionTokens!(maxCompletionTokens, context); - } - if (trait.cacheKey !== undefined) { - hooks.cacheKey = (key) => trait.cacheKey!(key, context); - } - if (trait.extractUsage !== undefined) { - hooks.extractUsage = (chunk) => trait.extractUsage!(chunk, context); - } - if (trait.reasoningKey !== undefined) { - hooks.reasoningKey = () => trait.reasoningKey!(context); - } - if (trait.uploadVideo !== undefined) { - hooks.uploadVideo = (input: string | VideoUploadInput, options?: GenerateOptions) => - trait.uploadVideo!(input, options, context); - } - } - - return Object.keys(hooks).length > 0 ? hooks : undefined; -} - -export interface AggregatedEndpoint { - readonly apiKeyEnv: readonly string[]; - readonly baseUrlEnv: readonly string[]; - readonly defaultBaseUrl?: string; -} - -export function traitEndpoint(traits: readonly ResolvedTrait[]): AggregatedEndpoint | undefined { - const apiKeyEnv: string[] = []; - const baseUrlEnv: string[] = []; - let defaultBaseUrl: string | undefined; - let declared = false; - for (const { trait, context } of traits) { - if (trait.endpoint === undefined) continue; - const endpoint: ProtocolEndpoint | undefined = trait.endpoint(context); - if (endpoint === undefined) continue; - declared = true; - if (endpoint.apiKeyEnv !== undefined) apiKeyEnv.push(endpoint.apiKeyEnv); - if (endpoint.baseUrlEnv !== undefined) baseUrlEnv.push(endpoint.baseUrlEnv); - if (endpoint.defaultBaseUrl !== undefined) defaultBaseUrl = endpoint.defaultBaseUrl; - } - return declared ? { apiKeyEnv, baseUrlEnv, defaultBaseUrl } : undefined; -} - -export function firstProcessEnv(names: readonly string[] | undefined): string | undefined { - if (names === undefined) return undefined; - for (const name of names) { - const value = process.env[name]; - if (value !== undefined && value.length > 0) return value; - } - return undefined; -} - -export function traitProvides( - traits: readonly ResolvedTrait[], -): Record | undefined { - let provides: Record | undefined; - for (const { trait, context } of traits) { - if (trait.provides === undefined) continue; - const declared = trait.provides(context); - if (declared === undefined) continue; - provides = { ...provides, ...declared }; - } - return provides; -} - -export function compactObject>(obj: T): Partial { - const out: Partial = {}; - for (const [key, value] of Object.entries(obj)) { - if (value !== undefined) { - (out as Record)[key] = value; - } - } - return out; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/reasoning-key.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/reasoning-key.ts deleted file mode 100644 index 064c4402c..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/reasoning-key.ts +++ /dev/null @@ -1,42 +0,0 @@ -export const KNOWN_REASONING_KEYS = [ - 'reasoning_content', - 'reasoning_details', - 'reasoning', -] as const; - -export type ReasoningKey = (typeof KNOWN_REASONING_KEYS)[number]; - -export const DEFAULT_REASONING_KEY: ReasoningKey = KNOWN_REASONING_KEYS[0]; - -export function extractReasoning( - source: unknown, - explicitKey?: string, -): { key: string; value: string } | undefined { - if (typeof source !== 'object' || source === null) return undefined; - const record = source as Record; - const keys: readonly string[] = explicitKey !== undefined ? [explicitKey] : KNOWN_REASONING_KEYS; - for (const key of keys) { - const value = record[key]; - if (typeof value === 'string') return { key, value }; - } - return undefined; -} - -export class ReasoningKeyDialect { - private _detected: string | undefined; - - constructor(private readonly _explicitKey?: string) {} - - observe(source: unknown): string | undefined { - const found = extractReasoning(source, this._explicitKey); - if (found === undefined) return undefined; - if (this._explicitKey === undefined) { - this._detected = found.key; - } - return found.value; - } - - outboundKey(): string { - return this._explicitKey ?? this._detected ?? DEFAULT_REASONING_KEY; - } -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/request-auth.ts b/packages/agent-core-v2/src/kosong/provider/bases/request-auth.ts deleted file mode 100644 index 5d3495c98..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/request-auth.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ChatProviderError } from '#/kosong/contract/errors'; -import type { ProviderRequestAuth } from '#/kosong/contract/provider'; - -export function requireProviderApiKey( - providerName: string, - auth: ProviderRequestAuth | undefined, - defaultApiKey?: string, -): string { - const apiKey = auth?.apiKey ?? defaultApiKey; - if (apiKey === undefined || apiKey.length === 0) { - throw new ChatProviderError( - `${providerName}: apiKey is required. Provide it via the constructor options, the provider's API-key environment variable, options.auth.apiKey on each request, or an OAuth login.`, - ); - } - return apiKey; -} - -export function mergeRequestHeaders( - defaultHeaders: Record | undefined, - requestHeaders: Record | undefined, -): Record | undefined { - const merged: Record = {}; - if (defaultHeaders !== undefined) { - Object.assign(merged, defaultHeaders); - } - if (requestHeaders !== undefined) { - Object.assign(merged, requestHeaders); - } - return Object.keys(merged).length > 0 ? merged : undefined; -} - -export function resolveAuthBackedClient( - state: { - readonly cachedClient: TClient | undefined; - readonly clientFactory: ((auth: ProviderRequestAuth) => TClient) | undefined; - }, - auth: ProviderRequestAuth | undefined, - build: (auth: ProviderRequestAuth | undefined) => TClient, -): TClient { - if (state.clientFactory !== undefined) { - return state.clientFactory(auth ?? {}); - } - if (auth === undefined && state.cachedClient !== undefined) { - return state.cachedClient; - } - return build(auth); -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/tool-call-id.ts b/packages/agent-core-v2/src/kosong/provider/bases/tool-call-id.ts deleted file mode 100644 index d6b174bce..000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/tool-call-id.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { BugIndicatingError } from '#/_base/errors/errors'; -import type { Message, ToolCall } from '#/kosong/contract/message'; -import type { ToolCallIdPolicy } from '#/kosong/contract/provider'; - -const EMPTY_TOOL_CALL_ID = 'tool_call'; -const TOOL_CALL_ID_SAFE_CHARS = /[^a-zA-Z0-9_-]/g; - -export function sanitizeToolCallId(id: string, maxLength?: number): string { - const sanitized = id.replace(TOOL_CALL_ID_SAFE_CHARS, '_'); - return maxLength === undefined ? sanitized : sanitized.slice(0, maxLength); -} - -export function sanitizeOpenAIResponsesCallId(id: string, maxLength?: number): string { - const [callId] = id.split('|', 1); - return sanitizeToolCallId(callId ?? id, maxLength); -} - -export function normalizeToolCallIdsForProvider( - messages: Message[], - policy: ToolCallIdPolicy, -): Message[] { - const rawIds = collectToolCallIds(messages); - if (rawIds.length === 0) return messages; - - const mappedIds = buildToolCallIdMap(rawIds, policy); - let changed = false; - const normalizedMessages = messages.map((message) => { - let messageChanged = false; - let toolCalls = message.toolCalls; - - if (message.toolCalls.length > 0) { - toolCalls = message.toolCalls.map((toolCall) => { - const mappedId = mappedIds.get(toolCall.id); - if (mappedId === undefined || mappedId === toolCall.id) return toolCall; - messageChanged = true; - return { ...toolCall, id: mappedId } satisfies ToolCall; - }); - } - - const toolCallId = - message.toolCallId === undefined ? undefined : mappedIds.get(message.toolCallId); - const mappedToolCallId = toolCallId ?? message.toolCallId; - if (mappedToolCallId !== message.toolCallId) { - messageChanged = true; - } - - if (!messageChanged) return message; - changed = true; - return { ...message, toolCalls, toolCallId: mappedToolCallId }; - }); - - return changed ? normalizedMessages : messages; -} - -function collectToolCallIds(messages: Message[]): string[] { - const ids: string[] = []; - const seen = new Set(); - const append = (id: string): void => { - if (seen.has(id)) return; - seen.add(id); - ids.push(id); - }; - - for (const message of messages) { - for (const toolCall of message.toolCalls) { - append(toolCall.id); - } - if (message.toolCallId !== undefined) { - append(message.toolCallId); - } - } - - return ids; -} - -function buildToolCallIdMap(rawIds: string[], policy: ToolCallIdPolicy): Map { - const mappedIds = new Map(); - const usedIds = new Set(); - - for (const rawId of rawIds) { - const normalized = policy.normalize(rawId); - if (normalized === rawId && normalized.length > 0) { - mappedIds.set(rawId, normalized); - usedIds.add(normalized); - } - } - - for (const rawId of rawIds) { - if (mappedIds.has(rawId)) continue; - const normalized = policy.normalize(rawId); - const unique = makeUniqueToolCallId(normalized, usedIds, policy.maxLength); - mappedIds.set(rawId, unique); - usedIds.add(unique); - } - - return mappedIds; -} - -function makeUniqueToolCallId( - normalized: string, - usedIds: Set, - maxLength: number | undefined, -): string { - const base = normalized.length > 0 ? normalized : EMPTY_TOOL_CALL_ID; - const candidate = truncateToolCallId(base, maxLength, ''); - if (!usedIds.has(candidate)) return candidate; - - for (let i = 2; ; i++) { - const suffix = `_${i}`; - const suffixed = truncateToolCallId(base, maxLength, suffix); - if (!usedIds.has(suffixed)) return suffixed; - } -} - -function truncateToolCallId(base: string, maxLength: number | undefined, suffix: string): string { - if (maxLength === undefined) return `${base}${suffix}`; - const baseLength = maxLength - suffix.length; - if (baseLength <= 0) { - throw new BugIndicatingError( - `Tool call id maxLength ${maxLength} is too small for suffix ${suffix}.`, - ); - } - return `${base.slice(0, baseLength)}${suffix}`; -} diff --git a/packages/agent-core-v2/src/kosong/provider/protocolAdapterRegistry.ts b/packages/agent-core-v2/src/kosong/provider/protocolAdapterRegistry.ts deleted file mode 100644 index ef5f601ea..000000000 --- a/packages/agent-core-v2/src/kosong/provider/protocolAdapterRegistry.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { LifecycleScope } from '#/app/scopes'; - -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { UNKNOWN_CAPABILITY } from '#/kosong/contract/capability'; -import type { ModelCapability } from '#/kosong/contract/capability'; -import { ChatProviderError } from '#/kosong/contract/errors'; -import type { ChatProvider } from '#/kosong/contract/provider'; -import { - IProtocolAdapterRegistry, - type ExplainedCapability, - type Protocol, - type ProtocolAdapterConfig, -} from '#/kosong/protocol/protocol'; -import { - getProtocolBase, - listProtocolBases, - type ProtocolBaseId, - type ResolvedAdapterIdentity, -} from '#/kosong/protocol/protocolBase'; -import type { ProtocolTrait, ResolvedTrait, TraitContext } from '#/kosong/protocol/protocolTrait'; - -import { getProviderDefinition } from './providerDefinition'; - -const CONFIG_DEFAULT_HEADERS_TRAIT: ProtocolTrait = { - defaultHeaders: (ctx) => - ctx.config.defaultHeaders === undefined ? undefined : { ...ctx.config.defaultHeaders }, -}; - -export class ProtocolAdapterRegistry implements IProtocolAdapterRegistry { - declare readonly _serviceBrand: undefined; - - supportedProtocols(): readonly Protocol[] { - return listProtocolBases().map((base) => base.id); - } - - resolveAdapterIdentity(protocol: Protocol, providerType?: string): ResolvedAdapterIdentity { - const definition = - providerType === undefined ? undefined : getProviderDefinition(providerType, protocol); - const baseId: ProtocolBaseId = protocol; - const traits: readonly ProtocolTrait[] = definition?.traits ?? []; - - const context: TraitContext = { - config: { protocol, providerType, modelName: '' }, - providerId: providerType, - }; - const resolved: ResolvedTrait[] = traits.map((trait) => ({ trait, context })); - resolved.push({ trait: CONFIG_DEFAULT_HEADERS_TRAIT, context }); - return { baseId, traits: resolved }; - } - - resolveProviderBaseId(protocol: Protocol, providerType?: string): ProtocolBaseId { - const definition = - providerType === undefined ? undefined : getProviderDefinition(providerType, protocol); - if (definition !== undefined) { - return definition.baseProtocol; - } - return protocol; - } - - resolveCapability(protocol: Protocol, modelName: string, providerType?: string): ModelCapability { - return this.explainCapability(protocol, modelName, providerType).capability; - } - - explainCapability( - protocol: Protocol, - modelName: string, - providerType?: string, - ): ExplainedCapability { - const identity = this.resolveAdapterIdentity(protocol, providerType); - let traitCapability: ModelCapability | undefined; - for (const { trait, context } of identity.traits) { - if (trait.capability === undefined) continue; - const capability = trait.capability(modelName, context); - if (capability !== undefined) { - traitCapability = capability; - } - } - if (traitCapability !== undefined) { - return { - capability: traitCapability, - source: { - kind: 'builtin', - detail: `trait capability hook (provider '${providerType ?? 'unregistered'}')`, - }, - }; - } - - const baseCapability = getProtocolBase(identity.baseId)?.capability?.(modelName); - if (baseCapability !== undefined) { - return { - capability: baseCapability, - source: { kind: 'builtin', detail: `protocol base '${identity.baseId}' catalog` }, - }; - } - return { - capability: UNKNOWN_CAPABILITY, - source: { kind: 'none', detail: 'no capability source knew this model' }, - }; - } - - createChatProvider(config: ProtocolAdapterConfig): ChatProvider { - const identity = this.resolveAdapterIdentity(config.protocol, config.providerType); - const traits: ResolvedTrait[] = identity.traits.map(({ trait }) => ({ - trait, - context: { config, providerId: config.providerType }, - })); - const base = getProtocolBase(identity.baseId); - if (base === undefined) { - throw new ChatProviderError( - `No protocol base registered for '${identity.baseId}'. Import the base's contrib module first.`, - ); - } - return base.createChatProvider({ config, traits }); - } -} - -registerScopedService( - LifecycleScope.App, - IProtocolAdapterRegistry, - ProtocolAdapterRegistry, - ScopeActivation.OnScopeCreated, - 'provider', -); diff --git a/packages/agent-core-v2/src/kosong/provider/provider.ts b/packages/agent-core-v2/src/kosong/provider/provider.ts deleted file mode 100644 index e6436506d..000000000 --- a/packages/agent-core-v2/src/kosong/provider/provider.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { Event, IWaitUntil } from '#/_base/event'; - -export type ProviderType = string; - -export interface OAuthRef { - storage: 'file' | 'keyring'; - key: string; - oauthHost?: string; -} - -export type ModelSource = 'static' | 'discover' | 'oauth-catalog'; - -export interface ProviderConfig { - modelSource?: ModelSource; - - baseUrl?: string; - customHeaders?: Record; - defaultModel?: string; - - type?: ProviderType; - apiKey?: string; - oauth?: OAuthRef; - env?: Record; - source?: Record; -} - -export type ProvidersSection = Record; - -export interface ProvidersChangedEvent { - readonly added: readonly string[]; - readonly removed: readonly string[]; - readonly changed: readonly string[]; -} - -export interface DefaultProviderChangedEvent { - readonly id: string | undefined; -} - -export interface IProviderService { - readonly _serviceBrand: undefined; - - readonly ready: Promise; - readonly onDidChangeProviders: Event; - readonly onDidChangeDefaultProvider: Event; - get(name: string): ProviderConfig | undefined; - list(): Readonly>; - getDefaultProvider(): string | undefined; - set(name: string, config: ProviderConfig): Promise; - delete(name: string): Promise; - loadAll(providers: ProvidersSection, defaultProvider: string | undefined): void; - replaceAll(providers: ProvidersSection): Promise; - setDefaultProvider(id: string | undefined): Promise; -} - -export const IProviderService: ServiceIdentifier = - createDecorator('providerService'); diff --git a/packages/agent-core-v2/src/kosong/provider/providerDefinition.ts b/packages/agent-core-v2/src/kosong/provider/providerDefinition.ts deleted file mode 100644 index 4ad5dac46..000000000 --- a/packages/agent-core-v2/src/kosong/provider/providerDefinition.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { BugIndicatingError } from '#/_base/errors/errors'; -import type { Protocol, ProtocolAdapterConfig } from '#/kosong/protocol/protocol'; -import type { - ProtocolEndpoint, - ProtocolTrait, - TraitContext, -} from '#/kosong/protocol/protocolTrait'; - -import type { ModelSource } from './provider'; - -export interface ProviderDefinition { - readonly id: string; - readonly baseProtocol: Protocol; - readonly traits: readonly ProtocolTrait[]; - readonly endpoint?: ProtocolEndpoint; - readonly hostHeaders?: 'full' | 'user-agent'; - readonly modelSource?: ModelSource; -} - -const providerDefinitions = new Map>(); - -export function registerProviderDefinition(definition: ProviderDefinition): void { - let byProtocol = providerDefinitions.get(definition.id); - if (byProtocol === undefined) { - byProtocol = new Map(); - providerDefinitions.set(definition.id, byProtocol); - } - if (byProtocol.has(definition.baseProtocol)) { - throw new BugIndicatingError( - `provider definition '${definition.id}' is already registered for protocol '${definition.baseProtocol}'`, - ); - } - byProtocol.set(definition.baseProtocol, definition); -} - -export function getProviderDefinition( - id: string, - protocol?: Protocol, -): ProviderDefinition | undefined { - const byProtocol = providerDefinitions.get(id); - if (byProtocol === undefined) return undefined; - if (protocol !== undefined) return byProtocol.get(protocol); - return byProtocol.values().next().value; -} - -export function getProviderDefinitions(id: string): readonly ProviderDefinition[] { - const byProtocol = providerDefinitions.get(id); - return byProtocol === undefined ? [] : [...byProtocol.values()]; -} - -export function hasProviderDefinition(id: string): boolean { - return providerDefinitions.has(id); -} - -export function isOAuthCatalogVendor(id: string | undefined): boolean { - if (id === undefined) return false; - return getProviderDefinitions(id).some( - (definition) => definition.modelSource === 'oauth-catalog', - ); -} - -export function listProviderDefinitions(): readonly ProviderDefinition[] { - return [...providerDefinitions.values()].flatMap((byProtocol) => [...byProtocol.values()]); -} - -export interface ResolvedProviderEndpoint { - readonly apiKey?: string; - readonly baseUrl?: string; -} - -export interface ExplainedProviderEndpoint { - readonly apiKey?: string; - readonly apiKeyEnvName?: string; - readonly baseUrl?: string; - readonly baseUrlEnvName?: string; - readonly baseUrlIsDefault?: boolean; -} - -export function explainProviderEndpoint( - providerType: string, - env: Readonly> = process.env, -): ExplainedProviderEndpoint { - const definition = getProviderDefinition(providerType); - if (definition === undefined) return {}; - const endpoint = - normalizeEndpointDeclaration(definition.endpoint) ?? aggregateTraitEndpoints(definition); - if (endpoint === undefined) return {}; - const apiKeyHit = firstEnvHit(endpoint.apiKeyEnv, env); - const baseUrlHit = firstEnvHit(endpoint.baseUrlEnv, env); - return { - ...(apiKeyHit !== undefined - ? { apiKey: apiKeyHit.value, apiKeyEnvName: apiKeyHit.name } - : undefined), - ...(baseUrlHit !== undefined - ? { baseUrl: baseUrlHit.value, baseUrlEnvName: baseUrlHit.name } - : endpoint.defaultBaseUrl !== undefined - ? { baseUrl: endpoint.defaultBaseUrl, baseUrlIsDefault: true } - : undefined), - }; -} - -export function resolveProviderEndpoint( - providerType: string, - env: Readonly> = process.env, -): ResolvedProviderEndpoint { - const { apiKey, baseUrl } = explainProviderEndpoint(providerType, env); - return { - ...(apiKey !== undefined ? { apiKey } : undefined), - ...(baseUrl !== undefined ? { baseUrl } : undefined), - }; -} - -interface AggregatedEndpointDeclaration { - readonly apiKeyEnv: readonly string[]; - readonly baseUrlEnv: readonly string[]; - readonly defaultBaseUrl?: string; -} - -function normalizeEndpointDeclaration( - endpoint: ProtocolEndpoint | undefined, -): AggregatedEndpointDeclaration | undefined { - if (endpoint === undefined) return undefined; - return { - apiKeyEnv: endpoint.apiKeyEnv === undefined ? [] : [endpoint.apiKeyEnv], - baseUrlEnv: endpoint.baseUrlEnv === undefined ? [] : [endpoint.baseUrlEnv], - defaultBaseUrl: endpoint.defaultBaseUrl, - }; -} - -function aggregateTraitEndpoints( - definition: ProviderDefinition, -): AggregatedEndpointDeclaration | undefined { - const config: ProtocolAdapterConfig = { - protocol: definition.baseProtocol, - providerType: definition.id, - modelName: '', - }; - const context: TraitContext = { config, providerId: definition.id }; - const apiKeyEnv: string[] = []; - const baseUrlEnv: string[] = []; - let defaultBaseUrl: string | undefined; - let declared = false; - for (const trait of definition.traits) { - if (trait.endpoint === undefined) continue; - const endpoint = trait.endpoint(context); - if (endpoint === undefined) continue; - declared = true; - if (endpoint.apiKeyEnv !== undefined) apiKeyEnv.push(endpoint.apiKeyEnv); - if (endpoint.baseUrlEnv !== undefined) baseUrlEnv.push(endpoint.baseUrlEnv); - if (endpoint.defaultBaseUrl !== undefined) defaultBaseUrl = endpoint.defaultBaseUrl; - } - return declared ? { apiKeyEnv, baseUrlEnv, defaultBaseUrl } : undefined; -} - -function firstEnvHit( - names: readonly string[], - env: Readonly>, -): { readonly name: string; readonly value: string } | undefined { - for (const name of names) { - const value = env[name]; - if (value !== undefined && value.length > 0) return { name, value }; - } - return undefined; -} diff --git a/packages/agent-core-v2/src/kosong/provider/providerService.ts b/packages/agent-core-v2/src/kosong/provider/providerService.ts deleted file mode 100644 index fcc9bb955..000000000 --- a/packages/agent-core-v2/src/kosong/provider/providerService.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { AsyncEmitter, type Event, type IWaitUntil } from '#/_base/event'; - -import { deepEqual, diffRecords, isEmptyDiff } from '../recordDiff'; - -import { - type DefaultProviderChangedEvent, - type ProviderConfig, - type ProvidersChangedEvent, - type ProvidersSection, - IProviderService, -} from './provider'; - -const NO_ABORT = new AbortController().signal; - -export class ProviderService extends Disposable implements IProviderService { - declare readonly _serviceBrand: undefined; - - private providers: Readonly> = {}; - private defaultProvider: string | undefined; - private hydrated = false; - private resolveReady!: () => void; - readonly ready: Promise = new Promise((resolve) => { - this.resolveReady = resolve; - }); - - private readonly _onDidChangeProviders = this._register( - new AsyncEmitter(), - ); - readonly onDidChangeProviders: Event = - this._onDidChangeProviders.event; - private readonly _onDidChangeDefaultProvider = this._register( - new AsyncEmitter(), - ); - readonly onDidChangeDefaultProvider: Event = - this._onDidChangeDefaultProvider.event; - - get(name: string): ProviderConfig | undefined { - return this.providers[name]; - } - - list(): Readonly> { - return this.providers; - } - - getDefaultProvider(): string | undefined { - return this.defaultProvider; - } - - loadAll(providers: ProvidersSection, defaultProvider: string | undefined): void { - void this.applyRecords(providers); - void this.applyDefaultProvider(defaultProvider); - if (!this.hydrated) { - this.hydrated = true; - this.resolveReady(); - } - } - - async replaceAll(providers: ProvidersSection): Promise { - await this.ready; - await this.applyRecords(providers); - } - - async set(name: string, config: ProviderConfig): Promise { - await this.ready; - if (deepEqual(this.providers[name], config)) return; - await this.applyRecords({ ...this.providers, [name]: config }); - } - - async delete(name: string): Promise { - await this.ready; - if (!(name in this.providers)) return; - const { [name]: _removed, ...rest } = this.providers; - await this.applyRecords(rest); - if (this.defaultProvider === name) { - await this.applyDefaultProvider(undefined); - } - } - - async setDefaultProvider(id: string | undefined): Promise { - await this.ready; - await this.applyDefaultProvider(id); - } - - private async applyRecords(next: Readonly>): Promise { - const diff = diffRecords(this.providers, next); - if (isEmptyDiff(diff)) return; - this.providers = { ...next }; - await this._onDidChangeProviders.fireAsync(diff, NO_ABORT); - } - - private async applyDefaultProvider(id: string | undefined): Promise { - const normalized = id !== undefined && id.trim().length === 0 ? undefined : id; - if (this.defaultProvider === normalized) return; - this.defaultProvider = normalized; - await this._onDidChangeDefaultProvider.fireAsync({ id: normalized }, NO_ABORT); - } -} - -registerScopedService( - LifecycleScope.App, - IProviderService, - ProviderService, - ScopeActivation.OnScopeCreated, - 'provider', -); diff --git a/packages/agent-core-v2/src/kosong/provider/providers/pythinker/pythinker-errors.ts b/packages/agent-core-v2/src/kosong/provider/providers/pythinker/pythinker-errors.ts deleted file mode 100644 index ff893a5f5..000000000 --- a/packages/agent-core-v2/src/kosong/provider/providers/pythinker/pythinker-errors.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - APIProviderQuotaExhaustedError, - parseRetryAfterMs, - parseTraceId, -} from '#/kosong/contract/errors'; - -const PYTHINKER_QUOTA_EXHAUSTED_ERROR_CODES = new Set(['exceeded_current_quota_error']); - -const PYTHINKER_QUOTA_EXHAUSTED_MESSAGE_PATTERNS = [ - /exceeded your current (?:token )?quota/, - /check your account balance/, - /insufficient balance/, - /recharge your account|please recharge/, - /account (?:is )?in arrears/, -] as const; - -function readStringProp(value: object, key: string): string | undefined { - const raw = (value as Record)[key]; - return typeof raw === 'string' ? raw : undefined; -} - -function readErrorObjectProp(value: object): object | undefined { - const raw = (value as Record)['error']; - return typeof raw === 'object' && raw !== null ? raw : undefined; -} - -function collectErrorCodes(error: object): string[] { - const codes: string[] = []; - let current: object | undefined = error; - for (let depth = 0; current !== undefined && depth < 3; depth += 1) { - const code = readStringProp(current, 'code'); - if (code !== undefined) codes.push(code); - const type = readStringProp(current, 'type'); - if (type !== undefined) codes.push(type); - current = readErrorObjectProp(current); - } - return codes; -} - -export function classifyPythinkerQuotaError(error: unknown): APIProviderQuotaExhaustedError | undefined { - if (typeof error !== 'object' || error === null) return undefined; - const status = (error as Record)['status']; - if (status !== 429) return undefined; - - const message = readStringProp(error, 'message') ?? ''; - const structuredHit = collectErrorCodes(error).some((code) => - PYTHINKER_QUOTA_EXHAUSTED_ERROR_CODES.has(code), - ); - const lowerMessage = message.toLowerCase(); - const wordingHit = PYTHINKER_QUOTA_EXHAUSTED_MESSAGE_PATTERNS.some((pattern) => - pattern.test(lowerMessage), - ); - if (!structuredHit && !wordingHit) return undefined; - - const requestId = readStringProp(error, 'requestID') ?? null; - const headers = (error as Record)['headers']; - return new APIProviderQuotaExhaustedError( - message, - requestId, - parseRetryAfterMs(headers), - parseTraceId(headers), - ); -} diff --git a/packages/agent-core-v2/src/kosong/provider/providers/pythinker/pythinker-files.ts b/packages/agent-core-v2/src/kosong/provider/providers/pythinker/pythinker-files.ts deleted file mode 100644 index 8c3d9d4e6..000000000 --- a/packages/agent-core-v2/src/kosong/provider/providers/pythinker/pythinker-files.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Blob, File } from 'node:buffer'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; - -import type OpenAI from 'openai'; -import OpenAIClient from 'openai'; - -import { ChatProviderError } from '#/kosong/contract/errors'; -import type { VideoURLPart } from '#/kosong/contract/message'; -import type { ProviderRequestAuth, VideoUploadInput } from '#/kosong/contract/provider'; - -import { convertOpenAIError } from '../../bases/openai/openai-common'; -import { - mergeRequestHeaders, - requireProviderApiKey, - resolveAuthBackedClient, -} from '../../bases/request-auth'; -import { classifyPythinkerQuotaError } from './pythinker-errors'; - -export interface PythinkerUploadOptions { - auth?: ProviderRequestAuth; - signal?: AbortSignal; -} - -export interface PythinkerFilesOptions { - apiKey?: string; - baseUrl: string; - defaultHeaders?: Record; - clientFactory?: (auth: ProviderRequestAuth) => OpenAI; -} - -export class PythinkerFiles { - private readonly _apiKey: string | undefined; - private readonly _baseUrl: string; - private readonly _defaultHeaders: Record | undefined; - private readonly _client: OpenAI | undefined; - private readonly _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined; - - constructor(options: PythinkerFilesOptions) { - this._apiKey = options.apiKey; - this._baseUrl = options.baseUrl; - this._defaultHeaders = options.defaultHeaders; - this._clientFactory = options.clientFactory; - this._client = - options.apiKey === undefined || options.apiKey.length === 0 - ? undefined - : new OpenAIClient({ - apiKey: options.apiKey, - baseURL: options.baseUrl, - defaultHeaders: options.defaultHeaders, - }); - } - - async uploadVideo( - input: string | VideoUploadInput, - options?: PythinkerUploadOptions, - ): Promise { - let file: unknown; - - if (typeof input === 'string') { - if (!fs.existsSync(input)) { - throw new ChatProviderError(`Video file not found: ${input}`); - } - const filename = path.basename(input); - const mimeType = guessMimeTypeFromExt(filename); - if (mimeType === undefined || !mimeType.startsWith('video/')) { - throw new ChatProviderError( - `PythinkerFiles.uploadVideo: file extension does not indicate a video type: ${filename}`, - ); - } - const data = await fs.promises.readFile(input); - const blob = new Blob([new Uint8Array(data)], { type: mimeType }); - file = new File([blob], filename, { type: mimeType }); - } else { - if (!input.mimeType.startsWith('video/')) { - throw new ChatProviderError(`Expected a video mime type, got ${input.mimeType}`); - } - const filename = input.filename ?? guessFilename(input.mimeType); - const bytes = input.data instanceof Uint8Array ? input.data : new Uint8Array(input.data); - const blob = new Blob([bytes as Uint8Array], { type: input.mimeType }); - file = new File([blob], filename, { type: input.mimeType }); - } - - let uploaded: { id: string }; - try { - const client = this._createClient(options?.auth); - uploaded = (await client.files.create( - { - file: file as never, - purpose: 'video' as never, - }, - options?.signal ? { signal: options.signal } : undefined, - )) as unknown as { id: string }; - } catch (error: unknown) { - throw convertOpenAIError(error, classifyPythinkerQuotaError); - } - - return { - type: 'video_url', - videoUrl: { - url: `ms://${uploaded.id}`, - id: uploaded.id, - }, - }; - } - - private _createClient(auth: ProviderRequestAuth | undefined): OpenAI { - return resolveAuthBackedClient( - { cachedClient: this._client, clientFactory: this._clientFactory }, - auth, - (a) => { - const defaultHeaders = mergeRequestHeaders(this._defaultHeaders, a?.headers); - return new OpenAIClient({ - apiKey: requireProviderApiKey('PythinkerFiles.uploadVideo', a, this._apiKey), - baseURL: this._baseUrl, - defaultHeaders, - }); - }, - ); - } -} - -function guessFilename(mimeType: string): string { - const ext = MIME_TO_EXT[mimeType.toLowerCase()] ?? 'bin'; - return `upload.${ext}`; -} - -const MIME_TO_EXT: Record = { - 'video/mp4': 'mp4', - 'video/mpeg': 'mpeg', - 'video/quicktime': 'mov', - 'video/webm': 'webm', - 'video/x-matroska': 'mkv', - 'video/x-msvideo': 'avi', - 'video/x-flv': 'flv', - 'video/3gpp': '3gp', -}; - -const EXT_TO_MIME: Record = Object.fromEntries( - Object.entries(MIME_TO_EXT).map(([mime, ext]) => [ext, mime]), -); - -function guessMimeTypeFromExt(filename: string): string | undefined { - const dot = filename.lastIndexOf('.'); - if (dot < 0) return undefined; - const ext = filename.slice(dot + 1).toLowerCase(); - return EXT_TO_MIME[ext]; -} diff --git a/packages/agent-core-v2/src/kosong/provider/providers/pythinker/pythinker-schema.ts b/packages/agent-core-v2/src/kosong/provider/providers/pythinker/pythinker-schema.ts deleted file mode 100644 index 12ed02f76..000000000 --- a/packages/agent-core-v2/src/kosong/provider/providers/pythinker/pythinker-schema.ts +++ /dev/null @@ -1,437 +0,0 @@ -import { Error2 } from '#/_base/errors/errors'; -import { ProtocolErrors } from '#/kosong/protocol/errors'; - -export function derefJsonSchema(schema: Record): Record { - const visited = new Set(); - const result = resolveNode(schema, schema, visited) as Record; - - if (!hasUnresolvedDefinitionRef(result, '$defs')) { - delete result['$defs']; - } - if (!hasUnresolvedDefinitionRef(result, 'definitions')) { - delete result['definitions']; - } - return result; -} - -type JsonSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null'; -type SchemaSlotKind = 'single' | 'array' | 'map' | 'schema-or-array'; -type StructuralJsonSchemaType = Extract; - -interface ChildSchemaSlot { - key: string; - kind: SchemaSlotKind; - parentType?: StructuralJsonSchemaType; -} - -const TYPE_COMPLETION_SKIP_KEYS = new Set([ - '$ref', - 'allOf', - 'anyOf', - 'else', - 'if', - 'not', - 'oneOf', - 'then', -]); - -const CHILD_SCHEMA_SLOTS = [ - { key: '$defs', kind: 'map' }, - { key: 'definitions', kind: 'map' }, - { key: 'dependencies', kind: 'map', parentType: 'object' }, - { key: 'dependentSchemas', kind: 'map', parentType: 'object' }, - { key: 'patternProperties', kind: 'map', parentType: 'object' }, - { key: 'properties', kind: 'map', parentType: 'object' }, - { key: 'additionalItems', kind: 'single', parentType: 'array' }, - { key: 'additionalProperties', kind: 'single', parentType: 'object' }, - { key: 'contains', kind: 'single', parentType: 'array' }, - { key: 'contentSchema', kind: 'single', parentType: 'string' }, - { key: 'else', kind: 'single' }, - { key: 'if', kind: 'single' }, - { key: 'not', kind: 'single' }, - { key: 'propertyNames', kind: 'single', parentType: 'object' }, - { key: 'then', kind: 'single' }, - { key: 'unevaluatedItems', kind: 'single', parentType: 'array' }, - { key: 'unevaluatedProperties', kind: 'single', parentType: 'object' }, - { key: 'allOf', kind: 'array' }, - { key: 'anyOf', kind: 'array' }, - { key: 'oneOf', kind: 'array' }, - { key: 'prefixItems', kind: 'array', parentType: 'array' }, - { key: 'items', kind: 'schema-or-array', parentType: 'array' }, -] as const satisfies readonly ChildSchemaSlot[]; - -const OBJECT_STRUCTURE_KEYS = new Set([ - ...childSchemaKeysForParentType('object'), - 'dependentRequired', - 'maxProperties', - 'minProperties', - 'required', -]); - -const ARRAY_STRUCTURE_KEYS = new Set([ - ...childSchemaKeysForParentType('array'), - 'maxContains', - 'maxItems', - 'minContains', - 'minItems', - 'uniqueItems', -]); - -const STRING_STRUCTURE_KEYS = new Set([ - ...childSchemaKeysForParentType('string'), - 'contentEncoding', - 'contentMediaType', - 'format', - 'maxLength', - 'minLength', - 'pattern', -]); - -const NUMERIC_STRUCTURE_KEYS = new Set([ - 'exclusiveMaximum', - 'exclusiveMinimum', - 'maximum', - 'minimum', - 'multipleOf', -]); - -export function normalizePythinkerToolSchema(schema: Record): Record { - return ensurePythinkerPropertyTypes(derefJsonSchema(schema)); -} - -function ensurePythinkerPropertyTypes(schema: Record): Record { - const normalized = cloneJsonValue(schema); - if (!isRecord(normalized)) { - throw new Error2( - ProtocolErrors.codes.PROVIDER_API_ERROR, - 'JSON Schema root must normalize to an object.', - ); - } - recurseSchema(normalized); - return normalized; -} - -function hasUnresolvedDefinitionRef(node: unknown, bucketKey: string): boolean { - if (Array.isArray(node)) { - return node.some((child) => hasUnresolvedDefinitionRef(child, bucketKey)); - } - if (typeof node === 'object' && node !== null) { - const obj = node as Record; - const ref = obj['$ref']; - if (typeof ref === 'string' && ref.startsWith(`#/${bucketKey}/`)) { - return true; - } - for (const [key, value] of Object.entries(obj)) { - if (key === bucketKey) continue; - if (hasUnresolvedDefinitionRef(value, bucketKey)) return true; - } - return false; - } - return false; -} - -function resolveNode(node: unknown, root: Record, visited: Set): unknown { - if (Array.isArray(node)) { - return node.map((item) => resolveNode(item, root, visited)); - } - - if (typeof node === 'object' && node !== null) { - const obj = node as Record; - - if (typeof obj['$ref'] === 'string') { - const ref = obj['$ref']; - if (isLocalJsonPointerRef(ref)) { - if (visited.has(ref)) { - return obj; - } - const resolvedRef = resolveLocalJsonPointer(root, ref); - if (resolvedRef.found) { - visited.add(ref); - const resolved = resolveNode(resolvedRef.value, root, visited); - visited.delete(ref); - if (typeof resolved === 'object' && resolved !== null && !Array.isArray(resolved)) { - const merged: Record = { ...(resolved as Record) }; - for (const [key, value] of Object.entries(obj)) { - if (key === '$ref') continue; - merged[key] = resolveNode(value, root, visited); - } - return merged; - } - return resolved; - } - } - return obj; - } - - const resolved: Record = {}; - for (const [key, value] of Object.entries(obj)) { - resolved[key] = resolveNode(value, root, visited); - } - return resolved; - } - - return node; -} - -function isLocalJsonPointerRef(ref: string): boolean { - return ref === '#' || ref.startsWith('#/'); -} - -function resolveLocalJsonPointer( - root: Record, - ref: string, -): { found: true; value: unknown } | { found: false } { - if (ref === '#') { - return { found: true, value: root }; - } - let current: unknown = root; - for (const rawPart of ref.slice(2).split('/')) { - const part = unescapeJsonPointerPart(rawPart); - if (isRecord(current)) { - if (!hasOwn(current, part)) { - return { found: false }; - } - current = current[part]; - } else if (Array.isArray(current)) { - const index = parseJsonPointerArrayIndex(part); - if (index === null || index >= current.length) { - return { found: false }; - } - current = current[index]; - } else { - return { found: false }; - } - } - return { found: true, value: current }; -} - -function unescapeJsonPointerPart(part: string): string { - return part.replaceAll('~1', '/').replaceAll('~0', '~'); -} - -function parseJsonPointerArrayIndex(part: string): number | null { - if (!/^(0|[1-9]\d*)$/.test(part)) { - return null; - } - return Number(part); -} - -function recurseSchema(node: unknown): void { - if (!isRecord(node)) { - return; - } - - visitChildSchemas(node, normalizeProperty); -} - -function visitChildSchemas(node: Record, visit: (schema: unknown) => void): void { - for (const { key, kind } of CHILD_SCHEMA_SLOTS) { - const value = node[key]; - if (kind === 'single') { - if (isRecord(value)) { - visit(value); - } - } else if (kind === 'array') { - if (Array.isArray(value)) { - for (const item of value) { - visit(item); - } - } - } else if (kind === 'map') { - if (isRecord(value)) { - for (const item of Object.values(value)) { - visit(item); - } - } - } else if (kind === 'schema-or-array') { - if (isRecord(value)) { - visit(value); - } else if (Array.isArray(value)) { - for (const item of value) { - visit(item); - } - } - } - } -} - -function childSchemaKeysForParentType(parentType: StructuralJsonSchemaType): string[] { - return CHILD_SCHEMA_SLOTS.flatMap((slot) => { - if (!('parentType' in slot) || slot.parentType !== parentType) { - return []; - } - return [slot.key]; - }); -} - -function normalizeProperty(node: unknown): void { - if (!isRecord(node)) { - return; - } - - if (!hasOwn(node, 'type') && !hasAnyKey(node, TYPE_COMPLETION_SKIP_KEYS)) { - const enumValues = node['enum']; - if (Array.isArray(enumValues) && enumValues.length > 0) { - node['type'] = inferTypeFromValues(enumValues); - } else if (hasOwn(node, 'const')) { - node['type'] = inferTypeFromValues([node['const']]); - } else { - node['type'] = inferTypeFromStructure(node); - } - } else if (!hasAnyKey(node, TYPE_COMPLETION_SKIP_KEYS) && typeof node['type'] === 'string') { - const enumValues = node['enum']; - if (Array.isArray(enumValues) && enumValues.length > 0) { - try { - const inferred = inferTypeFromValues(enumValues); - if (node['type'] !== inferred) { - node['type'] = inferred; - removeIrrelevantStructureKeys(node, inferred); - } - } catch {} - } else if (hasOwn(node, 'const')) { - try { - const inferred = inferTypeFromValues([node['const']]); - if (node['type'] !== inferred) { - node['type'] = inferred; - removeIrrelevantStructureKeys(node, inferred); - } - } catch {} - } - } - - recurseSchema(node); -} - -function removeIrrelevantStructureKeys( - node: Record, - newType: JsonSchemaType, -): void { - if (newType !== 'object') { - for (const key of OBJECT_STRUCTURE_KEYS) { - delete node[key]; - } - } - if (newType !== 'array') { - for (const key of ARRAY_STRUCTURE_KEYS) { - delete node[key]; - } - } -} - -function inferTypeFromStructure(schema: Record): JsonSchemaType { - if (hasAnyKey(schema, OBJECT_STRUCTURE_KEYS)) { - return 'object'; - } - if (hasAnyKey(schema, ARRAY_STRUCTURE_KEYS)) { - return 'array'; - } - if (hasAnyKey(schema, STRING_STRUCTURE_KEYS)) { - return 'string'; - } - if (hasAnyKey(schema, NUMERIC_STRUCTURE_KEYS)) { - return 'number'; - } - return 'string'; -} - -function inferTypeFromValues(values: unknown[]): JsonSchemaType { - const inferred = new Set(); - for (const value of values) { - const valueType = inferValueType(value); - if (valueType === undefined) { - throw new Error2( - ProtocolErrors.codes.PROVIDER_API_ERROR, - 'Cannot infer JSON Schema type from non-JSON enum or const value.', - ); - } - inferred.add(valueType); - } - const types = normalizeInferredTypes(inferred); - if (types.length === 1) { - const onlyType = types[0]; - if (onlyType === undefined) { - throw new Error2( - ProtocolErrors.codes.PROVIDER_API_ERROR, - 'Cannot infer JSON Schema type from an empty enum.', - ); - } - return onlyType; - } - throw new Error2( - ProtocolErrors.codes.PROVIDER_API_ERROR, - 'Mixed JSON Schema enum or const types are not supported by Pythinker tool schemas.', - ); -} - -function inferValueType(value: unknown): JsonSchemaType | undefined { - if (value === null) { - return 'null'; - } - if (Array.isArray(value)) { - return 'array'; - } - switch (typeof value) { - case 'string': - return 'string'; - case 'number': - return Number.isInteger(value) ? 'integer' : 'number'; - case 'boolean': - return 'boolean'; - case 'object': - return 'object'; - case 'bigint': - case 'function': - case 'symbol': - case 'undefined': - return undefined; - } - return undefined; -} - -function normalizeInferredTypes(types: Set): JsonSchemaType[] { - const normalized = new Set(types); - if (normalized.has('number')) { - normalized.delete('integer'); - } - const order: JsonSchemaType[] = [ - 'string', - 'number', - 'integer', - 'boolean', - 'object', - 'array', - 'null', - ]; - return order.filter((type) => normalized.has(type)); -} - -function hasAnyKey(obj: Record, keys: Set): boolean { - for (const key of keys) { - if (hasOwn(obj, key)) { - return true; - } - } - return false; -} - -function cloneJsonValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((item) => cloneJsonValue(item)); - } - if (isRecord(value)) { - const cloned: Record = {}; - for (const [key, child] of Object.entries(value)) { - cloned[key] = cloneJsonValue(child); - } - return cloned; - } - return value; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function hasOwn(obj: Record, key: string): boolean { - return Object.prototype.hasOwnProperty.call(obj, key); -} diff --git a/packages/agent-core-v2/src/kosong/provider/providers/pythinker/pythinker.contrib.ts b/packages/agent-core-v2/src/kosong/provider/providers/pythinker/pythinker.contrib.ts deleted file mode 100644 index 491db79e1..000000000 --- a/packages/agent-core-v2/src/kosong/provider/providers/pythinker/pythinker.contrib.ts +++ /dev/null @@ -1,260 +0,0 @@ -import type { ContentPart } from '#/kosong/contract/message'; -import type { Tool } from '#/kosong/contract/tool'; -import type { - ProtocolEndpoint, - ProtocolTrait, - TraitContext, -} from '#/kosong/protocol/protocolTrait'; - -import { type OpenAIToolParam, toolToOpenAI } from '../../bases/openai/openai-common'; -import { registerProviderDefinition } from '../../providerDefinition'; -import { classifyPythinkerQuotaError } from './pythinker-errors'; -import { PythinkerFiles } from './pythinker-files'; -import { normalizePythinkerToolSchema } from './pythinker-schema'; - -export const PYTHINKER_API_KEY_ENV = 'PYTHINKER_API_KEY'; -export const PYTHINKER_BASE_URL_ENV = 'PYTHINKER_BASE_URL'; -export const PYTHINKER_DEFAULT_BASE_URL = 'https://api.moonshot.ai/v1'; - -const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'; - -export interface GenerationKwargs { - max_tokens?: number | undefined; - max_completion_tokens?: number | undefined; - temperature?: number | undefined; - top_p?: number | undefined; - n?: number | undefined; - presence_penalty?: number | undefined; - frequency_penalty?: number | undefined; - stop?: string | string[] | undefined; - prompt_cache_key?: string | undefined; - extra_body?: ExtraBody; -} - -export interface PythinkerThinkingConfig { - type?: 'enabled' | 'disabled'; - effort?: string; - keep?: unknown; - [key: string]: unknown; -} - -export interface ExtraBody { - thinking?: PythinkerThinkingConfig; - [key: string]: unknown; -} - -export function convertPythinkerTool(tool: Tool): OpenAIToolParam { - if (tool.name.startsWith('$')) { - return { - type: 'builtin_function', - function: { name: tool.name }, - }; - } - const converted = toolToOpenAI(tool); - return { - ...converted, - function: { - ...converted.function, - parameters: normalizePythinkerToolSchema(tool.parameters), - }, - }; -} - -function isEffectivelyEmptyContent(parts: ContentPart[]): boolean { - for (const part of parts) { - if (part.type !== 'text') return false; - if (part.text.trim() !== '') return false; - } - return true; -} - -const filesByContext = new WeakMap(); - -function firstEnv(...names: readonly string[]): string | undefined { - for (const name of names) { - const value = process.env[name]; - if (value !== undefined && value.length > 0) return value; - } - return undefined; -} - -function resolveFiles(ctx: TraitContext): PythinkerFiles { - let files = filesByContext.get(ctx); - if (files === undefined) { - files = new PythinkerFiles({ - apiKey: ctx.config.apiKey ?? firstEnv(PYTHINKER_API_KEY_ENV), - baseUrl: ctx.config.baseUrl ?? firstEnv(PYTHINKER_BASE_URL_ENV) ?? PYTHINKER_DEFAULT_BASE_URL, - defaultHeaders: - ctx.config.defaultHeaders === undefined ? undefined : { ...ctx.config.defaultHeaders }, - }); - filesByContext.set(ctx, files); - } - return files; -} - -export const pythinkerOpenAITrait: ProtocolTrait = { - strictThinkingValidation: true, - - endpoint: () => ({ - apiKeyEnv: PYTHINKER_API_KEY_ENV, - baseUrlEnv: PYTHINKER_BASE_URL_ENV, - defaultBaseUrl: PYTHINKER_DEFAULT_BASE_URL, - }), - - convertError: (error) => classifyPythinkerQuotaError(error), - - cacheKey: (key) => ({ prompt_cache_key: key }), - - withThinking: (effort, options, generationKwargs) => { - const thinking: PythinkerThinkingConfig = - effort === 'off' - ? { type: 'disabled' } - : effort === 'on' - ? { type: 'enabled' } - : { type: 'enabled', effort }; - if (options.keep !== undefined) { - thinking.keep = options.keep; - } - const extraBody = generationKwargs['extra_body'] as ExtraBody | undefined; - return { extra_body: { ...extraBody, thinking } }; - }, - - preserveThinking: (generationKwargs) => { - const extraBody = generationKwargs['extra_body'] as ExtraBody | undefined; - const thinking = extraBody?.thinking; - if (thinking?.keep === 'all' && thinking.type !== 'disabled') { - return true; - } - return undefined; - }, - - withMaxCompletionTokens: (maxCompletionTokens) => ({ - max_completion_tokens: maxCompletionTokens, - }), - - buildParams: (params) => { - const { - extra_body: extraBody, - max_tokens: maxTokens, - max_completion_tokens: maxCompletionTokens, - ...rest - } = params; - const out: Record = { ...rest }; - const resolvedMaxCompletionTokens = maxCompletionTokens ?? maxTokens; - if (resolvedMaxCompletionTokens !== undefined) { - out['max_completion_tokens'] = resolvedMaxCompletionTokens; - } - if (extraBody !== undefined && extraBody !== null) { - Object.assign(out, extraBody); - } - return out; - }, - - convertTool: (tool) => convertPythinkerTool(tool), - - convertMessage: (message, converted) => { - if (message.role === 'assistant' && message.toolCalls.length > 0) { - const nonThinkParts = message.content.filter((part) => part.type !== 'think'); - if (isEffectivelyEmptyContent(nonThinkParts)) { - delete converted['content']; - } - } - - const convertedToolCalls = converted['tool_calls']; - if (Array.isArray(convertedToolCalls)) { - message.toolCalls.forEach((toolCall, index) => { - if (toolCall.extras === undefined) return; - const out = convertedToolCalls[index] as Record | undefined; - if (out !== undefined) { - out['extras'] = toolCall.extras; - } - }); - } - - if (message.tools !== undefined && message.tools.length > 0) { - converted['tools'] = message.tools.map((tool) => convertPythinkerTool(tool)); - } - - return converted; - }, - - extractUsage: (chunk) => { - const topLevel = chunk['usage']; - if (topLevel !== null && topLevel !== undefined && typeof topLevel === 'object') { - return topLevel as Record; - } - const choices = chunk['choices']; - if (!Array.isArray(choices) || choices.length === 0) { - return undefined; - } - const firstChoice = choices[0] as Record | undefined; - const choiceUsage = firstChoice?.['usage']; - if (choiceUsage !== null && choiceUsage !== undefined && typeof choiceUsage === 'object') { - return choiceUsage as Record; - } - return undefined; - }, - - uploadVideo: (input, options, ctx) => resolveFiles(ctx).uploadVideo(input, options), -}; - -export const pythinkerAnthropicTrait: ProtocolTrait = { - convertError: (error) => classifyPythinkerQuotaError(error), - - withThinking: (effort, _options, generationKwargs) => { - const seeded = generationKwargs['betaFeatures']; - const betaFeatures = (Array.isArray(seeded) ? (seeded as string[]) : []).filter( - (beta) => beta !== INTERLEAVED_THINKING_BETA, - ); - if (effort === 'off') { - return { - thinking: { type: 'disabled' }, - output_config: undefined, - betaFeatures, - }; - } - return { - thinking: { type: 'enabled' }, - output_config: effort === 'on' ? undefined : { effort }, - betaFeatures, - }; - }, -}; - -const pythinkerEndpoint: ProtocolEndpoint = { - apiKeyEnv: PYTHINKER_API_KEY_ENV, - baseUrlEnv: PYTHINKER_BASE_URL_ENV, - defaultBaseUrl: PYTHINKER_DEFAULT_BASE_URL, -}; - -export const pythinkerResponsesTrait: ProtocolTrait = { - endpoint: () => pythinkerEndpoint, - convertError: (error) => classifyPythinkerQuotaError(error), -}; - -registerProviderDefinition({ - id: 'pythinker', - baseProtocol: 'openai', - traits: [pythinkerOpenAITrait], - endpoint: pythinkerEndpoint, - hostHeaders: 'full', - modelSource: 'oauth-catalog', -}); - -registerProviderDefinition({ - id: 'pythinker', - baseProtocol: 'anthropic', - traits: [pythinkerAnthropicTrait], - endpoint: pythinkerEndpoint, - hostHeaders: 'full', - modelSource: 'oauth-catalog', -}); - -registerProviderDefinition({ - id: 'pythinker', - baseProtocol: 'openai_responses', - traits: [pythinkerResponsesTrait], - endpoint: pythinkerEndpoint, - hostHeaders: 'full', - modelSource: 'oauth-catalog', -}); diff --git a/packages/agent-core-v2/src/kosong/provider/providers/standard.contrib.ts b/packages/agent-core-v2/src/kosong/provider/providers/standard.contrib.ts deleted file mode 100644 index 6e075737f..000000000 --- a/packages/agent-core-v2/src/kosong/provider/providers/standard.contrib.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { registerProviderDefinition } from '../providerDefinition'; - -registerProviderDefinition({ - id: 'anthropic', - baseProtocol: 'anthropic', - traits: [], - endpoint: { apiKeyEnv: 'ANTHROPIC_API_KEY', baseUrlEnv: 'ANTHROPIC_BASE_URL' }, -}); - -registerProviderDefinition({ - id: 'openai', - baseProtocol: 'openai', - traits: [], - endpoint: { apiKeyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL' }, -}); - -registerProviderDefinition({ - id: 'openai_responses', - baseProtocol: 'openai_responses', - traits: [], - endpoint: { apiKeyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL' }, -}); - -registerProviderDefinition({ - id: 'google-genai', - baseProtocol: 'google-genai', - traits: [ - { endpoint: () => ({ apiKeyEnv: 'VERTEXAI_API_KEY', baseUrlEnv: 'GOOGLE_VERTEX_BASE_URL' }) }, - { endpoint: () => ({ apiKeyEnv: 'GOOGLE_API_KEY', baseUrlEnv: 'GOOGLE_GEMINI_BASE_URL' }) }, - ], -}); diff --git a/packages/agent-core-v2/src/kosong/recordDiff.ts b/packages/agent-core-v2/src/kosong/recordDiff.ts deleted file mode 100644 index 60c697601..000000000 --- a/packages/agent-core-v2/src/kosong/recordDiff.ts +++ /dev/null @@ -1,49 +0,0 @@ -export interface RecordDiff { - readonly added: readonly string[]; - readonly removed: readonly string[]; - readonly changed: readonly string[]; -} - -export function isEmptyDiff(diff: RecordDiff): boolean { - return diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0; -} - -export function diffRecords( - previous: Readonly> | undefined, - current: Readonly> | undefined, -): RecordDiff { - const prev = previous ?? {}; - const curr = current ?? {}; - const added: string[] = []; - const removed: string[] = []; - const changed: string[] = []; - for (const key of Object.keys(curr)) { - if (!(key in prev)) { - added.push(key); - } else if (!deepEqual(prev[key], curr[key])) { - changed.push(key); - } - } - for (const key of Object.keys(prev)) { - if (!(key in curr)) { - removed.push(key); - } - } - return { added, removed, changed }; -} - -export function deepEqual(a: unknown, b: unknown): boolean { - if (Object.is(a, b)) return true; - if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; - if (Array.isArray(a) !== Array.isArray(b)) return false; - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - if (aKeys.length !== bKeys.length) return false; - for (const key of aKeys) { - if (!Object.prototype.hasOwnProperty.call(b, key)) return false; - if (!deepEqual((a as Record)[key], (b as Record)[key])) { - return false; - } - } - return true; -} diff --git a/packages/agent-core-v2/src/llm-adapter/contract/errors.ts b/packages/agent-core-v2/src/llm-adapter/contract/errors.ts index a4369a28f..ebeeeca9c 100644 --- a/packages/agent-core-v2/src/llm-adapter/contract/errors.ts +++ b/packages/agent-core-v2/src/llm-adapter/contract/errors.ts @@ -426,6 +426,16 @@ export function errorFromLlmMessage(error: LlmErrorMessage): Error { traceIdFromHeadersRecord(error.headers), ); case 'quota_exhausted': + if (error.statusCode !== 429) { + return new APIStatusError( + error.statusCode, + error.message, + error.requestId, + error.retryAfterMs, + traceIdFromHeadersRecord(error.headers), + PROVIDER_API_ERROR_CODE, + ); + } return new APIProviderQuotaExhaustedError( error.message, error.requestId, diff --git a/packages/agent-core-v2/src/llm-adapter/model/model.ts b/packages/agent-core-v2/src/llm-adapter/model/model.ts index dc6d925fd..0e1ea0ca2 100644 --- a/packages/agent-core-v2/src/llm-adapter/model/model.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/model.ts @@ -50,6 +50,10 @@ export interface ModelRecord { export type ModelsSection = Record; +export function modelRecordProviderId(record: ModelRecord): string | undefined { + return record.providerId ?? record.provider; +} + export interface ModelsChangedEvent { readonly added: readonly string[]; readonly removed: readonly string[]; diff --git a/packages/agent-core-v2/src/session/subagent/subagentModelPolicyService.ts b/packages/agent-core-v2/src/session/subagent/subagentModelPolicyService.ts index b6b8a74b8..ee642d225 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentModelPolicyService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentModelPolicyService.ts @@ -3,8 +3,8 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Error2, ErrorCodes } from '#/errors'; import { IConfigService } from '#/app/config/config'; import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; -import { IModelCatalog } from '#/kosong/model/catalog'; -import { declaredDefaultEffortForModel, type ThinkingConfig } from '#/kosong/model/thinking'; +import { IModelCatalog } from '#/llm-adapter/model/catalog'; +import { declaredDefaultEffortForModel, type ThinkingConfig } from '#/llm-adapter/model/thinking'; import { INHERIT_SUBAGENT_MODEL_POLICY, diff --git a/packages/agent-core-v2/test/.skip/thinking.test.ts b/packages/agent-core-v2/test/.skip/thinking.test.ts index 01ba4b3c4..aa7c92d61 100644 --- a/packages/agent-core-v2/test/.skip/thinking.test.ts +++ b/packages/agent-core-v2/test/.skip/thinking.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { emptyUsage } from '#/kosong/contract/usage'; +import { emptyUsage } from '#human/llm/usage'; import { IEventBus } from '#/app/event/eventBus'; import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { IAgentProfileService } from '#/agent/profile/profile'; diff --git a/packages/agent-core-v2/test/app/auth/auth.test.ts b/packages/agent-core-v2/test/app/auth/auth.test.ts index addd0ff25..aee22dad0 100644 --- a/packages/agent-core-v2/test/app/auth/auth.test.ts +++ b/packages/agent-core-v2/test/app/auth/auth.test.ts @@ -23,10 +23,10 @@ import { WebSearchProviderService } from '#/app/auth/webSearch/webSearchService' import { ConfigRegistry } from '#/app/config/configService'; import { IConfigService } from '#/app/config/config'; import type { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; -import { IModelService, type ModelRecord } from '#/kosong/model/model'; -import { IProviderService, type ProviderConfig } from '#/kosong/provider/provider'; -import { ProviderService } from '#/kosong/provider/providerService'; -import '#/kosong/provider/providers/pythinker/pythinker.contrib'; +import { IModelService, type ModelRecord } from '#/llm-adapter/model/model'; +import { IProviderService, type ProviderConfig } from '#/llm-adapter/provider/provider'; +import { ProviderService } from '#/llm-adapter/provider/provider-service'; +import '#/llm-adapter/provider/provider-definition'; import { stubAgentIdentity } from '../agentIdentity/stubs'; import { stubBootstrap } from '../bootstrap/stubs'; @@ -112,10 +112,12 @@ describe('AuthSummaryService', () => { const providers: Record = { oauth: { type: 'pythinker', oauth: oauthRef }, api: { type: 'openai', apiKey: 'sk-example' }, + envkey: { type: 'anthropic', env: { ANTHROPIC_API_KEY: 'sk-ant-example' } }, }; const models: Record = { 'oauth/model': { provider: 'oauth', model: 'model', maxContextSize: 4096 }, 'api/model': { provider: 'api', model: 'model', maxContextSize: 4096 }, + 'envkey/model': { provider: 'envkey', model: 'model', maxContextSize: 4096 }, }; function create( @@ -190,6 +192,11 @@ describe('AuthSummaryService', () => { expect(getCachedAccessToken).toHaveBeenCalledWith('oauth', oauthRef); }); + it('accepts a vendor API key set in the provider env table', async () => { + const { service } = create(); + await expect(service.ensureReady('envkey/model')).resolves.toBeUndefined(); + }); + it('accepts API keys and rejects missing stored OAuth tokens', async () => { const { service, getCachedAccessToken } = create(); diff --git a/packages/agent-core-v2/test/llm-adapter/contract/opencode-billing-error.test.ts b/packages/agent-core-v2/test/llm-adapter/contract/opencode-billing-error.test.ts new file mode 100644 index 000000000..bb9efbbf8 --- /dev/null +++ b/packages/agent-core-v2/test/llm-adapter/contract/opencode-billing-error.test.ts @@ -0,0 +1,48 @@ +import { APIError as OpenAIAPIError } from 'openai'; +import { describe, expect, it } from 'vitest'; + +import { convertOpenAIError } from '#human/llm/requester/bases/openai/format'; +import { + APIProviderQuotaExhaustedError, + APIProviderRateLimitError, + ChatProviderError, + errorFromLlmMessage, + PROVIDER_API_ERROR_CODE, + PROVIDER_AUTH_ERROR_CODE, +} from '#/llm-adapter/contract/errors'; + +function classify(source: unknown): ChatProviderError { + const error = errorFromLlmMessage(convertOpenAIError(source)); + if (!(error instanceof ChatProviderError)) throw new Error('expected a ChatProviderError'); + return error; +} + +describe('OpenCode billing rejection classification', () => { + it.each([401, 402, 403])('keeps a %s insufficient-balance response out of provider.auth_error', (status) => { + const error = classify( + new OpenAIAPIError( + status, + { message: 'Insufficient balance. Manage your billing here: https://example.test/billing' }, + `${status} Insufficient balance. Manage your billing here: https://example.test/billing`, + new Headers({ 'x-request-id': 'req-123' }), + ), + ); + expect(error.code).toBe(PROVIDER_API_ERROR_CODE); + expect('statusCode' in error && error.statusCode).toBe(status); + }); + + it('classifies a structured 429 insufficient_quota response as exhausted quota', () => { + const source = new OpenAIAPIError(429, { code: 'insufficient_quota' }, 'Quota exhausted', new Headers()); + expect(classify(source)).toBeInstanceOf(APIProviderQuotaExhaustedError); + }); + + it('leaves generic 429 billing messages as rate limits', () => { + const source = new OpenAIAPIError(429, { message: 'Insufficient balance' }, 'Insufficient balance', new Headers()); + expect(classify(source)).toBeInstanceOf(APIProviderRateLimitError); + }); + + it('still classifies a normal 401 as provider.auth_error', () => { + const source = new OpenAIAPIError(401, { message: 'Invalid API key' }, '401 Invalid API key', new Headers()); + expect(classify(source).code).toBe(PROVIDER_AUTH_ERROR_CODE); + }); +}); diff --git a/packages/agent-core-v2/test/llm-adapter/model/modelService.test.ts b/packages/agent-core-v2/test/llm-adapter/model/modelService.test.ts deleted file mode 100644 index 5a5ba5ce6..000000000 --- a/packages/agent-core-v2/test/llm-adapter/model/modelService.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { modelsFromToml, modelsToToml } from '#/app/kosongConfig/configSection'; -import { type ModelRecord } from '#/llm-adapter/model/model'; -import { ModelService } from '#/llm-adapter/model/model-service'; - -describe('models TOML transforms', () => { - it('converts snake_case entries to camelCase and back', () => { - const from = modelsFromToml({ - k1: { - provider: 'pymodel', - model: 'kimi-k2', - max_context_size: 262144, - max_output_size: 8192, - display_name: 'K2', - reasoning_key: 'reasoning_content', - adaptive_thinking: true, - beta_api: true, - support_efforts: ['low', 'high'], - default_effort: 'high', - overrides: { max_output_size: 4096, default_effort: 'low' }, - }, - }) as Record>; - expect(from['k1']).toEqual({ - provider: 'pymodel', - model: 'kimi-k2', - maxContextSize: 262144, - maxOutputSize: 8192, - displayName: 'K2', - reasoningKey: 'reasoning_content', - adaptiveThinking: true, - betaApi: true, - supportEfforts: ['low', 'high'], - defaultEffort: 'high', - overrides: { maxOutputSize: 4096, defaultEffort: 'low' }, - }); - - const back = modelsToToml(from, undefined) as Record>; - expect(back['k1']).toEqual({ - provider: 'pymodel', - model: 'kimi-k2', - max_context_size: 262144, - max_output_size: 8192, - display_name: 'K2', - reasoning_key: 'reasoning_content', - adaptive_thinking: true, - beta_api: true, - support_efforts: ['low', 'high'], - default_effort: 'high', - overrides: { max_output_size: 4096, default_effort: 'low' }, - }); - }); -}); - -describe('ModelService', () => { - function createService(models: Readonly> = {}): ModelService { - const service = new ModelService(); - service.loadAll({ ...models }, undefined); - return service; - } - - it('resolves ready on the first loadAll and exposes the default pointer', async () => { - const service = new ModelService(); - let ready = false; - void service.ready.then(() => { - ready = true; - }); - await Promise.resolve(); - expect(ready).toBe(false); - - service.loadAll({ k1: { model: 'kimi-k2', maxContextSize: 262144 } }, 'k1'); - await service.ready; - expect(ready).toBe(true); - expect(service.getDefaultModel()).toBe('k1'); - }); - - it('supports CRUD and diffs state changes into onDidChangeModels', async () => { - const service = createService(); - const events: Array<{ - added: readonly string[]; - removed: readonly string[]; - changed: readonly string[]; - }> = []; - service.onDidChangeModels((e) => - events.push({ added: e.added, removed: e.removed, changed: e.changed }), - ); - - const k1: ModelRecord = { provider: 'pymodel', model: 'kimi-k2', maxContextSize: 262144 }; - await service.set('k1', k1); - expect(service.get('k1')).toEqual(k1); - expect(service.list()).toEqual({ k1 }); - expect(events).toEqual([{ added: ['k1'], removed: [], changed: [] }]); - - const updated: ModelRecord = { ...k1, displayName: 'K2' }; - await service.set('k1', updated); - expect(events.at(-1)).toEqual({ added: [], removed: [], changed: ['k1'] }); - - await service.set('k1', updated); - expect(events).toHaveLength(2); - - await service.delete('k1'); - expect(service.get('k1')).toBeUndefined(); - expect(events.at(-1)).toEqual({ added: [], removed: ['k1'], changed: [] }); - }); - - it('replaceAll replaces the records and keeps the default pointer', async () => { - const service = createService({ a: { model: 'm-a' }, b: { model: 'm-b' } }); - await service.setDefaultModel('a'); - - await service.replaceAll({ c: { model: 'm-c' } }); - expect(service.list()).toEqual({ c: { model: 'm-c' } }); - expect(service.getDefaultModel()).toBe('a'); - }); - - it('fires the pointer event only on real pointer changes', async () => { - const service = createService({ k1: { model: 'kimi-k2' } }); - const pointerEvents: Array = []; - service.onDidChangeDefaultModel((e) => pointerEvents.push(e.id)); - - await service.setDefaultModel('k1'); - await service.setDefaultModel('k1'); - expect(pointerEvents).toEqual(['k1']); - - await service.setDefaultModel(undefined); - expect(pointerEvents).toEqual(['k1', undefined]); - }); -}); diff --git a/scripts/check-identity-freeze.mjs b/scripts/check-identity-freeze.mjs index 7235c3ff4..4034e7ff4 100644 --- a/scripts/check-identity-freeze.mjs +++ b/scripts/check-identity-freeze.mjs @@ -15,7 +15,6 @@ export const FORBIDDEN_CAPABILITY_MARKER = 'moonshot-ai.kosong.UNKNOWN_CAPABILIT export const CAPABILITY_FILES = [ 'packages/kosong/src/capability.ts', - 'packages/agent-core-v2/src/kosong/contract/capability.ts', 'packages/agent-core-v2/src/llm-adapter/contract/capability.ts', 'packages/agent-core-v2/src/human/llm/capability.ts', ]; From 66897716bb2cec130dfe4f3a51bbd3d8ec8f2c9a Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 1 Oct 2026 19:02:15 -0400 Subject: [PATCH 4/5] test(agent-core-v2): pin vendor base-url env for non-pythinker env providers --- .changeset/provider-env-api-key-auth.md | 2 +- .../test/app/kosongConfig/envOverlay.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.changeset/provider-env-api-key-auth.md b/.changeset/provider-env-api-key-auth.md index 5bcb98694..03310eab6 100644 --- a/.changeset/provider-env-api-key-auth.md +++ b/.changeset/provider-env-api-key-auth.md @@ -3,4 +3,4 @@ "@pymodel/pythinker-desktop": patch --- -Accept a vendor API key set in a provider's env table (for example ANTHROPIC_API_KEY) instead of reporting the model as not logged in. +Accept a vendor API key or base URL set in a provider's env table (for example ANTHROPIC_API_KEY or ANTHROPIC_BASE_URL) instead of ignoring it. diff --git a/packages/agent-core-v2/test/app/kosongConfig/envOverlay.test.ts b/packages/agent-core-v2/test/app/kosongConfig/envOverlay.test.ts index 2bce0e489..d50402967 100644 --- a/packages/agent-core-v2/test/app/kosongConfig/envOverlay.test.ts +++ b/packages/agent-core-v2/test/app/kosongConfig/envOverlay.test.ts @@ -64,6 +64,20 @@ describe('pythinkerModelEnvOverlay.apply', () => { }); }); + it('honors the vendor base-url env for a non-pythinker env provider type', () => { + const effective: Record = { + providers: { [ENV_MODEL_PROVIDER_KEY]: { type: 'anthropic' } }, + }; + apply(effective, { + PYTHINKER_MODEL_NAME: 'my-model', + ANTHROPIC_BASE_URL: 'https://anthropic-proxy.example.test', + }); + expect((effective['providers'] as Record)[ENV_MODEL_PROVIDER_KEY]).toEqual({ + type: 'anthropic', + baseUrl: 'https://anthropic-proxy.example.test', + }); + }); + it('keeps an existing env-provider type and baseUrl untouched', () => { const effective: Record = { providers: { From fa170704fe614ebac791b0b652af3e23eac75287 Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 1 Oct 2026 19:04:30 -0400 Subject: [PATCH 5/5] test(agent-core-v2): isolate the MCP registry home with PYTHINKER_CODE_HOME --- packages/agent-core-v2/test/app/mcpRegistry/mcpRegistry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-core-v2/test/app/mcpRegistry/mcpRegistry.test.ts b/packages/agent-core-v2/test/app/mcpRegistry/mcpRegistry.test.ts index ae12513ee..1f01928bf 100644 --- a/packages/agent-core-v2/test/app/mcpRegistry/mcpRegistry.test.ts +++ b/packages/agent-core-v2/test/app/mcpRegistry/mcpRegistry.test.ts @@ -57,7 +57,7 @@ describe('McpRegistryService', () => { beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'pythinker-mcp-registry-home-')); - vi.stubEnv('KIMI_CODE_HOME', home); + vi.stubEnv('PYTHINKER_CODE_HOME', home); disposables = new DisposableStore(); tempDirs = [home]; pluginEntries = [];