diff --git a/packages/local-runtime/src/context/token-counter-adapters/openai-chat-tokenizers.ts b/packages/local-runtime/src/context/token-counter-adapters/openai-chat-tokenizers.ts index 496230d3e..97295d760 100644 --- a/packages/local-runtime/src/context/token-counter-adapters/openai-chat-tokenizers.ts +++ b/packages/local-runtime/src/context/token-counter-adapters/openai-chat-tokenizers.ts @@ -16,6 +16,9 @@ const ZHIPU_PATHS = new Set([ '/api/paas/v4', '/api/paas/v4/chat/completions', '/api/paas/v4/tokenizer', + '/api/coding/paas/v4', + '/api/coding/paas/v4/chat/completions', + '/api/coding/paas/v4/tokenizer', ]); const KIMI_PATHS = new Set(['/v1', '/v1/chat/completions', '/v1/tokenizers/estimate-token-count']); @@ -73,7 +76,7 @@ export const zhipuTokenizerTokenCounterAdapter: RemoteTokenCounterAdapter = { buildRequest: (ctx) => buildOpenAIChatTokenizerRequest( ctx, - '/api/paas/v4/tokenizer', + zhipuTokenizerEndpointPath(ctx.model.baseUrl), 'zhipu-tokenizer', ZHIPU_CHAT_COMPAT, ), @@ -94,6 +97,20 @@ export const kimiEstimateTokenCountAdapter: RemoteTokenCounterAdapter = { parseTokens: parseDataTotalTokens, }; +/** + * Zhipu exposes the same surface under two plan roots: `/api/paas/v4` (general + * API plan) and `/api/coding/paas/v4` (coding plan). The tokenizer endpoint + * lives under the same root as the configured base URL, so keep the request on + * the caller's plan instead of hardcoding the general-plan path. + */ +function zhipuTokenizerEndpointPath(baseUrl: string): string { + const url = parseUrl(baseUrl); + const pathname = url ? normalizePathname(url.pathname) : ''; + return pathname === '/api/coding/paas/v4' || pathname.startsWith('/api/coding/paas/v4/') + ? '/api/coding/paas/v4/tokenizer' + : '/api/paas/v4/tokenizer'; +} + function matchesOfficialOpenAIChatProvider( ctx: RemoteTokenCountContext, allowedHosts: ReadonlySet, diff --git a/packages/local-runtime/src/context/token-counter-adapters/responses.ts b/packages/local-runtime/src/context/token-counter-adapters/responses.ts index 602494fba..02889caa9 100644 --- a/packages/local-runtime/src/context/token-counter-adapters/responses.ts +++ b/packages/local-runtime/src/context/token-counter-adapters/responses.ts @@ -91,7 +91,7 @@ export function buildResponsesInputTokensUrl( if (options.stripMessagesCompatibilityPrefix) { rootPath = stripMessagesCompatibilityPrefix(rootPath); } - if (!rootPath.endsWith('/v1')) { + if (!endsWithVersionSegment(rootPath)) { rootPath = `${rootPath}/v1`; } url.pathname = `${rootPath}/responses/input_tokens`.replace(/\/{2,}/gu, '/'); @@ -109,11 +109,21 @@ export function buildResponsesInputTokensUrl( if (options.stripMessagesCompatibilityPrefix) { root = stripMessagesCompatibilityPrefix(root); } - if (!root.endsWith('/v1')) root = `${root}/v1`; + if (!endsWithVersionSegment(root)) root = `${root}/v1`; return `${root}/responses/input_tokens`; } } +/** + * Base URLs of OpenAI-compatible gateways are often already versioned + * (`/v1`, but also `/api/paas/v4`, `/api/coding/paas/v4`, ...). Appending + * `/v1` after an existing version segment can only produce a path that no + * gateway serves, so treat any trailing `/v` as the API root. + */ +function endsWithVersionSegment(root: string): boolean { + return /\/v\d+$/u.test(root); +} + function stripMessagesCompatibilityPrefix(root: string): string { return root.replace(/\/anthropic(?=\/v1$|$)/u, ''); } diff --git a/packages/local-runtime/test/unit/token-counter-adapter-routing.test.ts b/packages/local-runtime/test/unit/token-counter-adapter-routing.test.ts new file mode 100644 index 000000000..4cb8fdc89 --- /dev/null +++ b/packages/local-runtime/test/unit/token-counter-adapter-routing.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveRemoteTokenCounterAdapter } from '../../src/context/token-counter-adapters/registry.js'; +import { buildResponsesInputTokensUrl } from '../../src/context/token-counter-adapters/responses.js'; +import type { RemoteTokenCountContext } from '../../src/context/token-counter-adapters/types.js'; + +function makeContext(baseUrl: string, api = 'openai-completions'): RemoteTokenCountContext { + return { + model: { id: 'glm-5.3', api, provider: 'custom_provider', baseUrl, input: ['text'] }, + apiKey: 'sk-test', + systemPrompt: 'hi', + messages: [], + tools: [], + } as unknown as RemoteTokenCountContext; +} + +describe('remote token counter adapter routing (issue #258)', () => { + it('routes Zhipu general API-plan base URLs to the zhipu tokenizer', () => { + for (const baseUrl of [ + 'https://api.z.ai/api/paas/v4', + 'https://open.bigmodel.cn/api/paas/v4/chat/completions', + ]) { + const ctx = makeContext(baseUrl); + const adapter = resolveRemoteTokenCounterAdapter(ctx); + expect(adapter?.id).toBe('zhipu-tokenizer'); + expect(adapter?.buildRequest(ctx)?.url).toMatch(/\/api\/paas\/v4\/tokenizer$/u); + } + }); + + it('routes Zhipu coding-plan base URLs to the zhipu tokenizer on the coding-plan root', () => { + for (const host of ['api.z.ai', 'open.bigmodel.cn']) { + for (const path of ['/api/coding/paas/v4', '/api/coding/paas/v4/chat/completions']) { + const ctx = makeContext(`https://${host}${path}`); + const adapter = resolveRemoteTokenCounterAdapter(ctx); + expect(adapter?.id).toBe('zhipu-tokenizer'); + expect(adapter?.buildRequest(ctx)?.url).toBe( + `https://${host}/api/coding/paas/v4/tokenizer`, + ); + } + } + }); + + it('routes Kimi base URLs to the kimi estimator', () => { + const adapter = resolveRemoteTokenCounterAdapter(makeContext('https://api.moonshot.cn/v1')); + expect(adapter?.id).toBe('kimi-estimate-token-count'); + }); + + it('keeps unrelated OpenAI-compatible providers on generic-responses', () => { + const adapter = resolveRemoteTokenCounterAdapter( + makeContext('https://gateway.example.com/v1'), + ); + expect(adapter?.id).toBe('generic-responses'); + }); +}); + +describe('buildResponsesInputTokensUrl version handling (issue #258)', () => { + it('keeps /v1 base URLs unchanged', () => { + expect(buildResponsesInputTokensUrl('https://api.example.com/v1')).toBe( + 'https://api.example.com/v1/responses/input_tokens', + ); + }); + + it('appends /v1 when the base URL has no version segment', () => { + expect(buildResponsesInputTokensUrl('https://api.example.com')).toBe( + 'https://api.example.com/v1/responses/input_tokens', + ); + expect(buildResponsesInputTokensUrl('https://api.example.com/openai')).toBe( + 'https://api.example.com/openai/v1/responses/input_tokens', + ); + }); + + it('does not double the version for base URLs already ending in a version segment', () => { + expect(buildResponsesInputTokensUrl('https://api.z.ai/api/coding/paas/v4')).toBe( + 'https://api.z.ai/api/coding/paas/v4/responses/input_tokens', + ); + expect(buildResponsesInputTokensUrl('https://gateway.example.com/v2')).toBe( + 'https://gateway.example.com/v2/responses/input_tokens', + ); + }); + + it('strips chat/completions and responses suffixes before resolving the root', () => { + expect(buildResponsesInputTokensUrl('https://api.example.com/v1/chat/completions')).toBe( + 'https://api.example.com/v1/responses/input_tokens', + ); + expect( + buildResponsesInputTokensUrl('https://open.bigmodel.cn/api/paas/v4/chat/completions'), + ).toBe('https://open.bigmodel.cn/api/paas/v4/responses/input_tokens'); + expect(buildResponsesInputTokensUrl('https://api.example.com/v1/responses')).toBe( + 'https://api.example.com/v1/responses/input_tokens', + ); + }); + + it('keeps the MiniMax anthropic-compat stripping behavior stable', () => { + expect( + buildResponsesInputTokensUrl('https://api.minimaxi.com/anthropic', { + stripMessagesCompatibilityPrefix: true, + }), + ).toBe('https://api.minimaxi.com/v1/responses/input_tokens'); + }); + + it('falls back to string handling for non-URL base values', () => { + expect(buildResponsesInputTokensUrl('not a url/api/paas/v4')).toBe( + 'not a url/api/paas/v4/responses/input_tokens', + ); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 2f0c50fcb..d4b06b932 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -2653,6 +2653,7 @@ "packages/local-runtime/test/unit/thread-goal/host-integration-settlement.test.ts", "packages/local-runtime/test/unit/thread-goal/host-integration-testkit.ts", "packages/local-runtime/test/unit/thread-goal/host-integration-verifier-charge-rearm.test.ts", + "packages/local-runtime/test/unit/token-counter-adapter-routing.test.ts", "packages/mcode-tools-host/README.md", "packages/mcode-tools-host/package.json", "packages/mcode-tools-host/src/contracts.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 2063f581a..94c5ab43b 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -149,7 +149,8 @@ "packages/tui/test/unit/tui-feature-viewport-contract.test.ts", "packages/tui/test/unit/tui-session-history-flow.test.ts", "packages/tui/test/unit/tui-session-mutation-panels.test.ts", - "packages/tui/test/unit/tui-surface-host.test.ts" + "packages/tui/test/unit/tui-surface-host.test.ts", + "packages/local-runtime/test/unit/token-counter-adapter-routing.test.ts" ], "status-contract": [ "packages/tui/test/unit/tui-build-mode-contract.test.ts"