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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Expand Down Expand Up @@ -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,
),
Expand All @@ -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<string>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, '/');
Expand All @@ -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<digits>` 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, '');
}
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
1 change: 1 addition & 0 deletions release/public-source.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion test/vitest-suites.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading