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
12 changes: 7 additions & 5 deletions client/src/utils/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const GEMINI_CONTEXT_WINDOW = 1_048_576;
export const GROK_CONTEXT_WINDOW = 256_000;
export const KIMI_CONTEXT_WINDOW = 256_000;

// Keep in sync with server/lib/stageRunner.js.
// Keep in sync with server/services/stageRunner.js.
const KNOWN_MODEL_CONTEXT_WINDOWS = Object.freeze([
[/gpt[-_.:/]?5\.5(?:[-_.:/]|\b)/i, CODEX_CONTEXT_WINDOW],
[/gpt[-_.:/]?5\.4[-_.:/]?mini(?:[-_.:/]|\b)/i, 400_000],
Expand Down Expand Up @@ -648,7 +648,8 @@ export const isVisionCapableCliProvider = (provider) =>
* Tool-use (function-calling) capable model detector — mirror of `isToolUseModel`
* in server/lib/localModelHeuristics.js (and the TOOL_USE_RE inlined in
* server/lib/aiToolkit/providers.js). Keep all three in lockstep (the server libs
* can't be imported here). Ollama's /api/show `tools` capability is authoritative
* can't be imported here) — server/lib/localModelHeuristics.mirror.test.js reads
* this file as text and fails when the patterns stop matching the same ids. Ollama's /api/show `tools` capability is authoritative
* when known; this id regex is the fallback for bare model-id strings. The CoS
* agent harness depends on reliable tool-calling, so only these families should
* be selectable for a local-model-backed coding provider.
Expand Down Expand Up @@ -964,7 +965,7 @@ export const CONTEXT_WINDOW_SOURCE = Object.freeze({
* The window this provider's own `/models` catalog reported for this model, or
* `null` when it never mentioned it. Recorded by model refresh — the serving
* side's own declaration, so it outranks the hand-maintained regex table.
* Mirror of `catalogModelContextWindow` in server/lib/stageRunner.js.
* Mirror of `catalogModelContextWindow` in server/services/stageRunner.js.
*/
export function catalogModelContextWindow(provider, model) {
const windows = provider?.modelContextWindows;
Expand All @@ -976,7 +977,7 @@ export function catalogModelContextWindow(provider, model) {

/**
* The planning context window for a provider/model AND where it came from.
* Mirror of `effectiveContextWindow` in server/lib/stageRunner.js — the two
* Mirror of `effectiveContextWindow` in server/services/stageRunner.js — the two
* must resolve identically, or the card promises a budget the budgeter won't use.
*
* `{ tokens: null, source: null }` means nothing is known (an unrecognized model
Expand Down Expand Up @@ -1276,7 +1277,8 @@ export const modelCapabilityInfo = (provider, model, {
* front-end. MIRROR of `PROVIDER_GATEWAYS` in `server/lib/providerGateways.js`
* (and its vendored twin `server/lib/aiToolkit/internal/gateways.js`) — the
* browser cannot import server code, so the table is duplicated; keep the three
* in lockstep. `id` is simultaneously the OpenCode namespace, the
* in lockstep (server/lib/providerGateways.parity.test.js pins this copy's
* `id`/`label`/`apiKeyEnv`/`legacyMarker` rows against the server registry). `id` is simultaneously the OpenCode namespace, the
* `gatewayBacked` marker value, and the id of the sibling `api` record that
* owns the key.
*/
Expand Down
2 changes: 1 addition & 1 deletion server/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
|---|---|
| `dbTestGate.js` | `requireDbOrSkip(label, dbReady, reason)` keeps a missing local test database as a visible skipped suite, but throws when `PORTOS_REQUIRE_DB` is set so CI cannot pass after DB-backed suites disappear. |
| `gitTestRepo.js` | Shared real-git sandbox for integration tests (#4394): one initialized template (working tree + bare origin) per worker, then `fs.cp` into a fresh temp dir. `makeGitSandbox({ origin })`, `attachBareOrigin(scratch, repo)`, `materializeGitRepo(dest)`, `destroyGitSandbox`, plus `SKIP_HEAVY_INTEGRATION` (`VITEST_FAST=1`). Every entry point runs `assertTempPath` first, so a path outside `os.tmpdir()` throws instead of `git init`-ing or `rm -rf`-ing a real checkout (#4554). Still real git — just not rebuilt from `init`+`commit`+`push` in every `beforeEach`. |
| `mirrorParity.js` | Source-comparison primitives for the `*.mirror.test.js` server↔client parity tests: `stripCommentsAndNormalize` (so per-side commentary may diverge but logic may not), `extractDeclaration(src, name)` (balanced `{}`/`()`/`[]` walk over `function` / `async function` / `const`), and `compareDeclaration(serverSrc, clientSrc, name)`. Use these instead of hand-rolling a brace-walker per mirror. Pure — no `vitest` import — so callers own the assertions. |
| `mirrorParity.js` | Source-comparison primitives for the `*.mirror.test.js` server↔client parity tests: `stripCommentsAndNormalize` (so per-side commentary may diverge but logic may not), `extractDeclaration(src, name)` (balanced `{}`/`()`/`[]` walk over `function` / `async function` / `const`), `compareDeclaration(serverSrc, clientSrc, name)`, and `compareRegexDeclaration(serverSrc, clientSrc, serverName, clientName?)` / `regexAlternationSource(declText)` (for a regex spelled as a `new RegExp([…].join('|'), 'i')` array on one side and an inline `/…/i` literal on the other — compares what it matches, not how it is typeset, and returns `null` rather than a partial read on any shape it can't decode). Use these instead of hand-rolling a brace-walker per mirror. Pure — no `vitest` import — so callers own the assertions. |
| `mockPathsDataRoot.js` | Shared Vitest helpers for `PATHS.data → temp dir` and no-peer record creation guards. |
| `settingsTestUtil.js` | `bindSettingsFile(dataRoot)` → `writeSettingsFile`/`mergeSettingsFile`: direct settings.json disk writes that also drop the `getSettings()` read cache (dynamic-import reset) so a stale cache can't survive a bypass-`save()` write. |
| `testHelper.js` | Test helpers: `request()` (supertest-style HTTP) + `mockJsonResponse`/`mockTextResponse` (fetch `Response` mocks with `.text()`, `.json()`, and a `headers.get` content-type), `startLoopbackServer(app)`/`closeLoopbackServer(server)`/`waitForAbort(signal)` for tests that need a real socket (raw disconnects, SSE streaming) that `request()`'s run-to-completion fetch harness can't model, plus the source-scan pair `collectServerSources()` / `readServerSource(rel)` (and `SERVER_DIR`) used by the whole-tree guard suites — `spawnCwd.test.js` (#3193) and `cliChildEnv.test.js` (#3194). Those guards overlap deliberately, so they share one definition of "a source file"; change the ignore rules here and both move together. Cross-platform trio: `posixPath(v)` normalizes a RECEIVED path before comparing it to a POSIX-spelled literal (no-op on POSIX — never normalize the expectation, which would hide a genuinely wrong path), and `resolveTestPython()` returns an interpreter that actually runs, probing by execution because Windows ships a `python` Store-alias stub that exists but fails; `null` when there is none, for `describe.skipIf`; `pinPlatform(value)` pins `process.platform` and returns a restore that reinstates the ORIGINAL descriptor (deleting the pin when there was none) — it carries the one hazard every hand-rolled pin had to rediscover: never pin above an import that loads a native addon, which picks its prebuilt binary off the platform at load time (#4085). |
3 changes: 2 additions & 1 deletion server/lib/aiToolkit/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ const execFileAsync = (file, args, options) =>
// Tool-use (function-calling) capable model families. Inlined here because the
// aiToolkit is self-contained (no imports out to server/lib). MIRROR of
// TOOL_USE_RE in server/lib/localModelHeuristics.js and isToolUseModel in
// client/src/utils/providers.js — keep all three in lockstep.
// client/src/utils/providers.js — keep all three in lockstep
// (server/lib/localModelHeuristics.mirror.test.js fails when they drift).
const TOOL_USE_RE = new RegExp([
'qwen',
'llama-?3\\.[1-9]', 'llama-?4',
Expand Down
10 changes: 6 additions & 4 deletions server/lib/localModelHeuristics.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
* generation/fallback run — the cause of the nomic-embed-text fallback bug)
* - localLlm.getStatus (recommend a best-fit editorial model)
*
* The client mirrors `isEmbeddingModel` + `isVisionModel` in
* client/src/utils/providers.js — keep the regexes in lockstep (the
* aiToolkit/lib dirs can't be imported there).
* The client mirrors `isEmbeddingModel` + `isVisionModel` + `isToolUseModel`
* in client/src/utils/providers.js — keep the regexes in lockstep (the
* aiToolkit/lib dirs can't be imported there). `localModelHeuristics.mirror.test.js`
* enforces that, by what each pattern matches rather than by its text.
*/

// Embedding-only models — never valid for chat/generation. The bge/nomic/e5/gte
Expand Down Expand Up @@ -171,7 +172,8 @@ export function isVisionModel(model) {
// Gemma 4), so the gemma rule is anchored to the family AND the version.
//
// MIRRORED in client/src/utils/providers.js (isToolUseModel) and inlined in
// server/lib/aiToolkit/providers.js (TOOL_USE_RE) — keep all three in lockstep.
// server/lib/aiToolkit/providers.js (TOOL_USE_RE) — keep all three in lockstep;
// `localModelHeuristics.mirror.test.js` fails when any of them drifts.
const TOOL_USE_RE = new RegExp([
'qwen',
'llama-?3\\.[1-9]', 'llama-?4',
Expand Down
93 changes: 93 additions & 0 deletions server/lib/localModelHeuristics.mirror.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Mirror parity test for the local-model capability regexes, which exist in
* three copies by architecture:
* 1. server/lib/localModelHeuristics.js — authoritative;
* 2. client/src/utils/providers.js — the browser cannot import server code;
* 3. server/lib/aiToolkit/providers.js — the vendored toolkit may not import
* out of its own directory (see aiToolkit/AGENTS.md), so TOOL_USE_RE is
* inlined there too.
*
* All three were pinned by a "keep all three in lockstep" comment and by
* per-side hardcoded id lists (`providers.test.js`'s
* `describe('isToolUseModel (mirror of server localModelHeuristics)')` never
* reads the server file), so a family added to one copy alone left every suite
* green. The consequence is user-visible in both directions: a model the server
* accepts for tool use gets a "no known tool use" warning in the agent picker,
* and an embedding model the client hasn't learned to recognise is offered in a
* generation picker, where the daemon answers `400 … does not support chat`.
*
* The server spells VISION_RE / TOOL_USE_RE as a multi-line array of
* alternatives so each one can carry its own comment; the client inlines the
* same pattern as a single literal. Declaration TEXT therefore cannot be
* compared — `compareRegexDeclaration` compares what the two patterns match
* instead. Typesetting is irrelevant; the accepted id set is not. The toolkit
* copy is the array form on both sides, so it is compared as text.
*/

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { compareDeclaration, compareRegexDeclaration } from './mirrorParity.js';

const __dirname = dirname(fileURLToPath(import.meta.url));

const SERVER_PATH = resolve(__dirname, 'localModelHeuristics.js');
const CLIENT_PATH = resolve(__dirname, '../../client/src/utils/providers.js');
const TOOLKIT_PATH = resolve(__dirname, 'aiToolkit/providers.js');

// [server declaration, the client predicate that inlines it]
const MIRRORED_REGEXES = [
['EMBEDDING_RE', 'isEmbeddingModel'],
['VISION_RE', 'isVisionModel'],
['TOOL_USE_RE', 'isToolUseModel'],
];

describe('localModelHeuristics↔client providers capability-regex mirror parity', () => {
const serverSrc = readFileSync(SERVER_PATH, 'utf8');
const clientSrc = readFileSync(CLIENT_PATH, 'utf8');

it('both files are non-empty', () => {
expect(serverSrc.length).toBeGreaterThan(100);
expect(clientSrc.length).toBeGreaterThan(100);
});

for (const [serverName, clientName] of MIRRORED_REGEXES) {
it(`${serverName} accepts the same ids as the client's ${clientName}`, () => {
const { serverDecl, clientDecl, serverSource, clientSource } =
compareRegexDeclaration(serverSrc, clientSrc, serverName, clientName);

expect(serverDecl, `server/lib/localModelHeuristics.js is missing: ${serverName}`).not.toBeNull();
expect(clientDecl, `client/src/utils/providers.js is missing: ${clientName}`).not.toBeNull();
expect(
serverSource,
`server/lib/localModelHeuristics.js#${serverName} is neither a /…/i literal nor the new RegExp([…].join('|'), 'i') form`,
).not.toBeNull();
expect(
clientSource,
`client/src/utils/providers.js#${clientName} no longer inlines a /…/i literal`,
).not.toBeNull();
expect(
clientSource,
`"${serverName}" diverged from the client's ${clientName} — the server copy is authoritative; port the alternatives verbatim`,
).toBe(serverSource);
});
}
});

describe('localModelHeuristics↔aiToolkit TOOL_USE_RE mirror parity', () => {
const serverSrc = readFileSync(SERVER_PATH, 'utf8');
const toolkitSrc = readFileSync(TOOLKIT_PATH, 'utf8');

it('is present and identical in both server copies (code only)', () => {
const { serverDecl, clientDecl, serverNorm, clientNorm } =
compareDeclaration(serverSrc, toolkitSrc, 'TOOL_USE_RE');

expect(serverDecl, 'server/lib/localModelHeuristics.js is missing: TOOL_USE_RE').not.toBeNull();
expect(clientDecl, 'server/lib/aiToolkit/providers.js is missing: TOOL_USE_RE').not.toBeNull();
expect(
clientNorm,
'TOOL_USE_RE diverged — the localModelHeuristics copy is authoritative; port the change verbatim',
).toBe(serverNorm);
});
});
123 changes: 123 additions & 0 deletions server/lib/mirrorParity.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,126 @@ export function compareDeclaration(serverSrc, clientSrc, name) {
match: serverNorm != null && serverNorm === clientNorm,
};
}

// One single-quoted JS string literal at the head of the remaining array body,
// plus its trailing separator.
const NEXT_STRING_LITERAL_RE = /^'((?:[^'\\]|\\.)*)'\s*(?:,\s*)?/;
// Each accepted spelling of a mirrored capability regex, anchored to the WHOLE
// normalized declaration. Anchoring is what makes an unrecognized shape fail
// closed: a search-anywhere pattern would happily read a decoy array out of a
// declaration that assigns something else entirely, and report the mirror as
// intact. Group 1 is the alternation body in every one of them.
//
// 1. `const NAME = new RegExp([ 'a', 'b' ].join('|'), 'i');` — the server's
// array form, so each alternative can carry its own comment;
// 2. `const NAME = /…/i;` — a plain literal;
// 3. `export const isX = (id) => <guards> && /…/i.test(id);` — the browser's
// predicate form, where the literal is inlined at the end of a guard chain.
const DECLARATION_FORMS = [
/^(?:export\s+)?const\s+\w+\s*=\s*new RegExp\(\s*\[(.*)\]\s*\.join\('\|'\)\s*,\s*'i'\s*\)\s*;$/,
// Greedy on purpose: `[-_/:]` puts an unescaped `/` mid-pattern, so a lazy
// walk would end the literal inside a character class.
/^(?:export\s+)?const\s+\w+\s*=\s*\/(.*)\/i\s*;$/,
/^(?:export\s+)?const\s+\w+\s*=\s*\(\w+\)\s*=>\s*(?:[^/]*&&\s*)?\/(.*)\/i\.test\(\w+\)\s*;$/,
];
// Only the first form's body is a list of alternatives; the other two capture
// the finished pattern.
const [ARRAY_FORM_RE] = DECLARATION_FORMS;

/**
* The regex fragment a single-quoted source literal denotes, or null when it
* uses an escape this reader cannot decode by inspection.
*
* Only `\\` (a backslash the regex engine will actually see, which is how every
* alternative spells `\d` / `\.`) and `\'` are decodable here. `\x2e`, `\u002e`
* and `\n` all denote something OTHER than their own text — `'\x2e'` is `.`,
* which matches ANY character — so copying them through verbatim would let a
* client literal that means something different compare equal. These guards
* exist to catch drift, so an unreadable literal fails CLOSED rather than
* being guessed at.
*/
function decodeRegexFragmentLiteral(body) {
let out = '';
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch !== '\\') {
out += ch;
continue;
}
const escaped = body[i + 1];
if (escaped !== '\\' && escaped !== "'") return null;
out += escaped;
i++;
}
return out;
}

/**
* Every alternative in an array body, or null when the body holds anything
* other than a comma-separated run of decodable string literals.
*
* Failing closed on the leftovers is the point: a `[...SHARED, 'extra']` or a
* `[NAMED_FRAGMENT]` element read by a scan-for-literals pass would silently
* vanish from the comparison, and the mirror it is supposed to pin would drift
* green.
*/
function parseAlternativeList(body) {
const alternatives = [];
let rest = body.trim();
while (rest.length > 0) {
const match = NEXT_STRING_LITERAL_RE.exec(rest);
if (!match) return null;
const decoded = decodeRegexFragmentLiteral(match[1]);
if (decoded === null) return null;
alternatives.push(decoded);
rest = rest.slice(match[0].length);
}
return alternatives;
}

/**
* The alternation source of a capability regex, whichever of the accepted
* spellings above the declaration is typeset in.
*
* `/` is normalized (an escaped `\/` and a bare `/` mean the same thing to the
* engine, and only one of them is legal inside a literal outside a character
* class), so the two sides may differ in slash escaping but not in what they
* accept. Returns null for any shape not in `DECLARATION_FORMS` — a mirror the
* reader cannot fully account for must fail its guard, not skip past it.
*/
export function regexAlternationSource(declText) {
if (declText == null) return null;
const norm = stripCommentsAndNormalize(declText);
const form = DECLARATION_FORMS.find((re) => re.test(norm));
if (!form) return null;
const [, body] = form.exec(norm);
const source = form === ARRAY_FORM_RE ? parseAlternativeList(body)?.join('|') ?? null : body;
return source == null ? null : source.replace(/\\\//g, '/');
}

/**
* Compare a case-insensitive regex mirrored across two files that spell it
* differently — an array of per-alternative strings on one side, a single
* inline literal on the other — by what it MATCHES rather than by its text.
*
* `compareDeclaration` can't be used there: the two typesettings never compare
* equal even when they accept exactly the same ids, and forcing one side to
* adopt the other's form is churn for no behavioural gain.
*
* `clientName` defaults to `serverName`; pass it when the client inlines the
* pattern inside a differently-named predicate (`isToolUseModel` wrapping what
* the server declares as `TOOL_USE_RE`).
*/
export function compareRegexDeclaration(serverSrc, clientSrc, serverName, clientName = serverName) {
const serverDecl = extractDeclaration(serverSrc, serverName);
const clientDecl = extractDeclaration(clientSrc, clientName);
const serverSource = regexAlternationSource(serverDecl);
const clientSource = regexAlternationSource(clientDecl);
return {
serverDecl,
clientDecl,
serverSource,
clientSource,
match: serverSource != null && serverSource === clientSource,
};
}
Loading