@zhijiewang/openharness 2.12.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -276,38 +276,62 @@ program
276
276
  : opts.permissionMode !== "ask"
277
277
  ? opts.permissionMode
278
278
  : (savedConfig?.permissionMode ?? "ask");
279
- // Auto-detect provider or prompt for setup
279
+ // Auto-detect provider or launch the setup wizard
280
280
  let provider;
281
281
  let resolvedModel;
282
- try {
282
+ const tryCreateProvider = async () => {
283
283
  const { createProvider } = await import("./providers/index.js");
284
284
  const overrides = {};
285
- if (savedConfig?.apiKey)
286
- overrides.apiKey = savedConfig.apiKey;
287
- if (savedConfig?.baseUrl)
288
- overrides.baseUrl = savedConfig.baseUrl;
289
- const result = await createProvider(effectiveModel, Object.keys(overrides).length ? overrides : undefined);
285
+ const fresh = readOhConfig();
286
+ if (fresh?.apiKey)
287
+ overrides.apiKey = fresh.apiKey;
288
+ if (fresh?.baseUrl)
289
+ overrides.baseUrl = fresh.baseUrl;
290
+ const targetModel = fresh?.model ?? effectiveModel;
291
+ return createProvider(targetModel, Object.keys(overrides).length ? overrides : undefined);
292
+ };
293
+ try {
294
+ const result = await tryCreateProvider();
290
295
  provider = result.provider;
291
296
  resolvedModel = result.model;
292
297
  }
293
298
  catch (_err) {
294
- // First-run experience: guide the user
295
- console.log();
296
- console.log(" Welcome to OpenHarness!");
297
- console.log();
298
- console.log(" To get started, choose a provider:");
299
- console.log();
300
- console.log(" Local (free, no API key):");
301
- console.log(" npx openharness --model ollama/llama3");
302
- console.log(" npx openharness --model ollama/qwen2.5:7b-instruct");
303
- console.log();
304
- console.log(" Cloud (needs API key in env var):");
305
- console.log(" OPENAI_API_KEY=sk-... npx openharness --model gpt-4o");
306
- console.log(" ANTHROPIC_API_KEY=sk-ant-... npx openharness --model claude-sonnet-4-6");
307
- console.log();
308
- console.log(" Make sure Ollama is running: ollama serve");
309
- console.log();
310
- process.exit(0);
299
+ // First-run: launch the interactive wizard in TTY mode; fall back to
300
+ // static help text for non-TTY (CI, piped stdin, etc.).
301
+ if (process.stdout.isTTY && process.stdin.isTTY) {
302
+ const { default: InitWizard } = await import("./components/InitWizard.js");
303
+ const { waitUntilExit } = render(_jsx(InitWizard, { onDone: () => { } }));
304
+ await waitUntilExit();
305
+ try {
306
+ const result = await tryCreateProvider();
307
+ provider = result.provider;
308
+ resolvedModel = result.model;
309
+ }
310
+ catch {
311
+ console.log();
312
+ console.log(" Setup incomplete. Run 'oh init' to try again, or set a provider via --model.");
313
+ console.log();
314
+ process.exit(0);
315
+ }
316
+ }
317
+ else {
318
+ console.log();
319
+ console.log(" Welcome to OpenHarness!");
320
+ console.log();
321
+ console.log(" To get started, choose a provider:");
322
+ console.log();
323
+ console.log(" Local (free, no API key):");
324
+ console.log(" npx openharness --model ollama/llama3");
325
+ console.log(" npx openharness --model ollama/qwen2.5:7b-instruct");
326
+ console.log();
327
+ console.log(" Cloud (needs API key in env var):");
328
+ console.log(" OPENAI_API_KEY=sk-... npx openharness --model gpt-4o");
329
+ console.log(" ANTHROPIC_API_KEY=sk-ant-... npx openharness --model claude-sonnet-4-6");
330
+ console.log();
331
+ console.log(" Make sure Ollama is running: ollama serve");
332
+ console.log();
333
+ process.exit(0);
334
+ }
311
335
  }
312
336
  const mcpTools = await loadMcpTools();
313
337
  const mcpNames = connectedMcpServers();
@@ -0,0 +1,16 @@
1
+ /**
2
+ * OS keychain backend for MCP OAuth tokens.
3
+ *
4
+ * Wraps @napi-rs/keyring (optional dependency). All functions catch every error
5
+ * and return an "unavailable" sentinel so the orchestrator in oauth-storage.ts
6
+ * can fall back to the filesystem store without any user-visible disruption.
7
+ */
8
+ import type { OhCredentials } from "./oauth-storage-fs.js";
9
+ /** Clear the cached module reference. For tests only. */
10
+ export declare function _resetForTesting(): void;
11
+ /** True iff @napi-rs/keyring loaded successfully AND the platform has an Entry class. */
12
+ export declare function keychainAvailable(): boolean;
13
+ export declare function saveCredentialsKeychain(name: string, creds: OhCredentials): boolean;
14
+ export declare function loadCredentialsKeychain(name: string): OhCredentials | undefined;
15
+ export declare function deleteCredentialsKeychain(name: string): boolean;
16
+ //# sourceMappingURL=oauth-keychain.d.ts.map
@@ -0,0 +1,70 @@
1
+ /**
2
+ * OS keychain backend for MCP OAuth tokens.
3
+ *
4
+ * Wraps @napi-rs/keyring (optional dependency). All functions catch every error
5
+ * and return an "unavailable" sentinel so the orchestrator in oauth-storage.ts
6
+ * can fall back to the filesystem store without any user-visible disruption.
7
+ */
8
+ import { createRequire } from "node:module";
9
+ const SERVICE = "openharness-mcp";
10
+ const nodeRequire = createRequire(import.meta.url);
11
+ let entryCtorCache;
12
+ function getEntryCtor() {
13
+ if (entryCtorCache !== undefined)
14
+ return entryCtorCache;
15
+ try {
16
+ const mod = nodeRequire("@napi-rs/keyring");
17
+ entryCtorCache = mod.Entry;
18
+ }
19
+ catch {
20
+ entryCtorCache = null;
21
+ }
22
+ return entryCtorCache;
23
+ }
24
+ /** Clear the cached module reference. For tests only. */
25
+ export function _resetForTesting() {
26
+ entryCtorCache = undefined;
27
+ }
28
+ /** True iff @napi-rs/keyring loaded successfully AND the platform has an Entry class. */
29
+ export function keychainAvailable() {
30
+ return getEntryCtor() !== null;
31
+ }
32
+ export function saveCredentialsKeychain(name, creds) {
33
+ const Ctor = getEntryCtor();
34
+ if (!Ctor)
35
+ return false;
36
+ try {
37
+ new Ctor(SERVICE, name).setPassword(JSON.stringify(creds));
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ export function loadCredentialsKeychain(name) {
45
+ const Ctor = getEntryCtor();
46
+ if (!Ctor)
47
+ return undefined;
48
+ try {
49
+ const raw = new Ctor(SERVICE, name).getPassword();
50
+ if (!raw)
51
+ return undefined;
52
+ return JSON.parse(raw);
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ export function deleteCredentialsKeychain(name) {
59
+ const Ctor = getEntryCtor();
60
+ if (!Ctor)
61
+ return false;
62
+ try {
63
+ new Ctor(SERVICE, name).deletePassword();
64
+ return true;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ //# sourceMappingURL=oauth-keychain.js.map
@@ -0,0 +1,23 @@
1
+ export type OhCredentials = {
2
+ issuerUrl: string;
3
+ clientInformation: {
4
+ client_id: string;
5
+ client_secret?: string;
6
+ } & Record<string, unknown>;
7
+ tokens: {
8
+ access_token: string;
9
+ refresh_token?: string;
10
+ expires_at?: number;
11
+ token_type?: string;
12
+ scope?: string;
13
+ };
14
+ codeVerifier?: string;
15
+ updatedAt: string;
16
+ };
17
+ /** Atomically write credentials for one server. Creates the directory with 0o700 on first use. */
18
+ export declare function saveCredentials(storageDir: string, name: string, creds: OhCredentials): Promise<void>;
19
+ /** Load credentials. Returns undefined on missing file OR corrupt JSON. Warns on world/group-readable mode. */
20
+ export declare function loadCredentials(storageDir: string, name: string): Promise<OhCredentials | undefined>;
21
+ /** Idempotent delete — ENOENT is swallowed. */
22
+ export declare function deleteCredentials(storageDir: string, name: string): Promise<void>;
23
+ //# sourceMappingURL=oauth-storage-fs.d.ts.map
@@ -0,0 +1,58 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ function pathFor(storageDir, name) {
4
+ return join(storageDir, `${name}.json`);
5
+ }
6
+ /** Atomically write credentials for one server. Creates the directory with 0o700 on first use. */
7
+ export async function saveCredentials(storageDir, name, creds) {
8
+ const filePath = pathFor(storageDir, name);
9
+ const tmpPath = `${filePath}.tmp`;
10
+ await fs.mkdir(dirname(filePath), { recursive: true, mode: 0o700 });
11
+ const body = JSON.stringify(creds, null, 2);
12
+ await fs.writeFile(tmpPath, body, { mode: 0o600 });
13
+ await fs.rename(tmpPath, filePath);
14
+ }
15
+ /** Load credentials. Returns undefined on missing file OR corrupt JSON. Warns on world/group-readable mode. */
16
+ export async function loadCredentials(storageDir, name) {
17
+ const filePath = pathFor(storageDir, name);
18
+ let raw;
19
+ try {
20
+ raw = await fs.readFile(filePath, "utf8");
21
+ }
22
+ catch (err) {
23
+ if (err.code === "ENOENT")
24
+ return undefined;
25
+ throw err;
26
+ }
27
+ try {
28
+ if (process.platform !== "win32") {
29
+ const s = await fs.stat(filePath);
30
+ if ((s.mode & 0o077) !== 0) {
31
+ console.warn(`[mcp] credentials file for '${name}' is world/group-readable; run 'chmod 600 ${filePath}'`);
32
+ }
33
+ }
34
+ }
35
+ catch {
36
+ // stat failure is non-fatal for load
37
+ }
38
+ try {
39
+ return JSON.parse(raw);
40
+ }
41
+ catch {
42
+ console.warn(`[mcp] credentials file for '${name}' is corrupt; ignoring`);
43
+ return undefined;
44
+ }
45
+ }
46
+ /** Idempotent delete — ENOENT is swallowed. */
47
+ export async function deleteCredentials(storageDir, name) {
48
+ const filePath = pathFor(storageDir, name);
49
+ try {
50
+ await fs.unlink(filePath);
51
+ }
52
+ catch (err) {
53
+ if (err.code === "ENOENT")
54
+ return;
55
+ throw err;
56
+ }
57
+ }
58
+ //# sourceMappingURL=oauth-storage-fs.js.map
@@ -1,23 +1,28 @@
1
- export type OhCredentials = {
2
- issuerUrl: string;
3
- clientInformation: {
4
- client_id: string;
5
- client_secret?: string;
6
- } & Record<string, unknown>;
7
- tokens: {
8
- access_token: string;
9
- refresh_token?: string;
10
- expires_at?: number;
11
- token_type?: string;
12
- scope?: string;
13
- };
14
- codeVerifier?: string;
15
- updatedAt: string;
16
- };
17
- /** Atomically write credentials for one server. Creates the directory with 0o700 on first use. */
1
+ /**
2
+ * OAuth token storage orchestrator.
3
+ *
4
+ * Prefers the OS keychain via `oauth-keychain.ts` when available and not
5
+ * opted out via `credentials.storage: "filesystem"`. Falls back to the
6
+ * filesystem store in `oauth-storage-fs.ts` on any keychain failure.
7
+ *
8
+ * Public API unchanged: callers in oauth.ts and commands/mcp-auth.ts
9
+ * continue to import `saveCredentials` / `loadCredentials` /
10
+ * `deleteCredentials` / `OhCredentials` from this module.
11
+ */
12
+ export type { OhCredentials } from "./oauth-storage-fs.js";
13
+ import type { OhCredentials } from "./oauth-storage-fs.js";
14
+ /**
15
+ * Save credentials. Tries keychain first when available; falls back to
16
+ * filesystem on any keychain failure.
17
+ */
18
18
  export declare function saveCredentials(storageDir: string, name: string, creds: OhCredentials): Promise<void>;
19
- /** Load credentials. Returns undefined on missing file OR corrupt JSON. Warns on world/group-readable mode. */
19
+ /**
20
+ * Load credentials. Checks keychain first (when available), then filesystem.
21
+ * If both have entries for the same name, keychain wins.
22
+ */
20
23
  export declare function loadCredentials(storageDir: string, name: string): Promise<OhCredentials | undefined>;
21
- /** Idempotent delete — ENOENT is swallowed. */
24
+ /**
25
+ * Delete credentials from BOTH keychain and filesystem. Idempotent.
26
+ */
22
27
  export declare function deleteCredentials(storageDir: string, name: string): Promise<void>;
23
28
  //# sourceMappingURL=oauth-storage.d.ts.map
@@ -1,58 +1,55 @@
1
- import { promises as fs } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- function pathFor(storageDir, name) {
4
- return join(storageDir, `${name}.json`);
1
+ /**
2
+ * OAuth token storage orchestrator.
3
+ *
4
+ * Prefers the OS keychain via `oauth-keychain.ts` when available and not
5
+ * opted out via `credentials.storage: "filesystem"`. Falls back to the
6
+ * filesystem store in `oauth-storage-fs.ts` on any keychain failure.
7
+ *
8
+ * Public API unchanged: callers in oauth.ts and commands/mcp-auth.ts
9
+ * continue to import `saveCredentials` / `loadCredentials` /
10
+ * `deleteCredentials` / `OhCredentials` from this module.
11
+ */
12
+ import { readOhConfig } from "../harness/config.js";
13
+ import { deleteCredentialsKeychain, keychainAvailable, loadCredentialsKeychain, saveCredentialsKeychain, } from "./oauth-keychain.js";
14
+ import { deleteCredentials as deleteFs, loadCredentials as loadFs, saveCredentials as saveFs, } from "./oauth-storage-fs.js";
15
+ function shouldUseKeychain() {
16
+ // Explicit opt-out via env var (used by the test runner to isolate tests
17
+ // from the real OS keychain). Accepts "disabled", "false", "0", or "off".
18
+ const envOpt = (process.env.OH_KEYCHAIN ?? "").toLowerCase();
19
+ if (envOpt === "disabled" || envOpt === "false" || envOpt === "0" || envOpt === "off")
20
+ return false;
21
+ const cfg = readOhConfig();
22
+ if (cfg?.credentials?.storage === "filesystem")
23
+ return false;
24
+ return keychainAvailable();
5
25
  }
6
- /** Atomically write credentials for one server. Creates the directory with 0o700 on first use. */
26
+ /**
27
+ * Save credentials. Tries keychain first when available; falls back to
28
+ * filesystem on any keychain failure.
29
+ */
7
30
  export async function saveCredentials(storageDir, name, creds) {
8
- const filePath = pathFor(storageDir, name);
9
- const tmpPath = `${filePath}.tmp`;
10
- await fs.mkdir(dirname(filePath), { recursive: true, mode: 0o700 });
11
- const body = JSON.stringify(creds, null, 2);
12
- await fs.writeFile(tmpPath, body, { mode: 0o600 });
13
- await fs.rename(tmpPath, filePath);
31
+ if (shouldUseKeychain() && saveCredentialsKeychain(name, creds))
32
+ return;
33
+ await saveFs(storageDir, name, creds);
14
34
  }
15
- /** Load credentials. Returns undefined on missing file OR corrupt JSON. Warns on world/group-readable mode. */
35
+ /**
36
+ * Load credentials. Checks keychain first (when available), then filesystem.
37
+ * If both have entries for the same name, keychain wins.
38
+ */
16
39
  export async function loadCredentials(storageDir, name) {
17
- const filePath = pathFor(storageDir, name);
18
- let raw;
19
- try {
20
- raw = await fs.readFile(filePath, "utf8");
21
- }
22
- catch (err) {
23
- if (err.code === "ENOENT")
24
- return undefined;
25
- throw err;
26
- }
27
- try {
28
- if (process.platform !== "win32") {
29
- const s = await fs.stat(filePath);
30
- if ((s.mode & 0o077) !== 0) {
31
- console.warn(`[mcp] credentials file for '${name}' is world/group-readable; run 'chmod 600 ${filePath}'`);
32
- }
33
- }
34
- }
35
- catch {
36
- // stat failure is non-fatal for load
37
- }
38
- try {
39
- return JSON.parse(raw);
40
- }
41
- catch {
42
- console.warn(`[mcp] credentials file for '${name}' is corrupt; ignoring`);
43
- return undefined;
40
+ if (shouldUseKeychain()) {
41
+ const fromKc = loadCredentialsKeychain(name);
42
+ if (fromKc)
43
+ return fromKc;
44
44
  }
45
+ return loadFs(storageDir, name);
45
46
  }
46
- /** Idempotent delete — ENOENT is swallowed. */
47
+ /**
48
+ * Delete credentials from BOTH keychain and filesystem. Idempotent.
49
+ */
47
50
  export async function deleteCredentials(storageDir, name) {
48
- const filePath = pathFor(storageDir, name);
49
- try {
50
- await fs.unlink(filePath);
51
- }
52
- catch (err) {
53
- if (err.code === "ENOENT")
54
- return;
55
- throw err;
56
- }
51
+ if (keychainAvailable())
52
+ deleteCredentialsKeychain(name);
53
+ await deleteFs(storageDir, name);
57
54
  }
58
55
  //# sourceMappingURL=oauth-storage.js.map
@@ -33,20 +33,26 @@ export function createFallbackProvider(primary, fallbacks) {
33
33
  ];
34
34
  for (let i = 0; i < providers.length; i++) {
35
35
  const p = providers[i];
36
+ let hasYielded = false;
36
37
  try {
37
- let _hasYielded = false;
38
38
  for await (const event of p.provider.stream(messages, systemPrompt, tools, p.model)) {
39
- _hasYielded = true;
39
+ hasYielded = true;
40
40
  yield event;
41
41
  }
42
- _activeFallback = i === 0 ? null : p.provider.name;
42
+ if (i > 0) {
43
+ console.warn(`[provider] fell back from ${primary.name} to ${p.provider.name}`);
44
+ _activeFallback = p.provider.name;
45
+ }
46
+ else {
47
+ _activeFallback = null;
48
+ }
43
49
  return;
44
50
  }
45
51
  catch (err) {
46
- // Mid-stream failure: can't un-send events, propagate error
47
- if (i > 0 || !isRetriableError(err))
52
+ // Mid-stream failure OR non-retriable OR fallback error: propagate.
53
+ if (i > 0 || !isRetriableError(err) || hasYielded)
48
54
  throw err;
49
- // Pre-stream failure on primary: try next provider
55
+ // Pre-stream retriable failure on primary only: try next provider.
50
56
  _activeFallback = null;
51
57
  }
52
58
  }
@@ -63,7 +69,13 @@ export function createFallbackProvider(primary, fallbacks) {
63
69
  const p = providers[i];
64
70
  try {
65
71
  const result = await p.provider.complete(messages, systemPrompt, tools, p.model);
66
- _activeFallback = i === 0 ? null : p.provider.name;
72
+ if (i > 0) {
73
+ console.warn(`[provider] fell back from ${primary.name} to ${p.provider.name}`);
74
+ _activeFallback = p.provider.name;
75
+ }
76
+ else {
77
+ _activeFallback = null;
78
+ }
67
79
  return result;
68
80
  }
69
81
  catch (err) {
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Provider factory — create the right provider from a model string.
3
3
  */
4
+ import { readOhConfig } from "../harness/config.js";
4
5
  import { AnthropicProvider } from "./anthropic.js";
6
+ import { createFallbackProvider } from "./fallback.js";
5
7
  import { LlamaCppProvider } from "./llamacpp.js";
6
8
  import { OllamaProvider } from "./ollama.js";
7
9
  import { OpenAIProvider } from "./openai.js";
@@ -29,8 +31,22 @@ export async function createProvider(modelArg, overrides) {
29
31
  defaultModel: model,
30
32
  ...overrides,
31
33
  };
32
- const provider = createProviderInstance(providerName, config);
33
- return { provider, model };
34
+ const primary = createProviderInstance(providerName, config);
35
+ const fallbackCfgs = readOhConfig()?.fallbackProviders ?? [];
36
+ if (fallbackCfgs.length === 0) {
37
+ return { provider: primary, model };
38
+ }
39
+ const fallbacks = fallbackCfgs.map((fb) => ({
40
+ provider: createProviderInstance(fb.provider, {
41
+ name: fb.provider,
42
+ apiKey: fb.apiKey ?? process.env[`${fb.provider.toUpperCase()}_API_KEY`],
43
+ baseUrl: fb.baseUrl,
44
+ defaultModel: fb.model ?? model,
45
+ }),
46
+ model: fb.model,
47
+ }));
48
+ const wrapped = createFallbackProvider(primary, fallbacks);
49
+ return { provider: wrapped, model };
34
50
  }
35
51
  export { createProviderInstance, guessProviderFromModel };
36
52
  function createProviderInstance(name, config) {
@@ -45,4 +45,8 @@ export declare class ModelRouter {
45
45
  /** Get all configured tiers */
46
46
  get tiers(): Record<ModelTier, string>;
47
47
  }
48
+ /** Record the router's selection for a session. Keeps only the most recent 256 sessions. */
49
+ export declare function recordRouteSelection(sessionId: string, result: RouteResult): void;
50
+ /** Retrieve the most recent selection for a session, or undefined. */
51
+ export declare function getRouteSelection(sessionId: string): RouteResult | undefined;
48
52
  //# sourceMappingURL=router.d.ts.map
@@ -58,4 +58,23 @@ export class ModelRouter {
58
58
  };
59
59
  }
60
60
  }
61
+ const ROUTE_SELECTION_CAP = 256;
62
+ const routeSelections = new Map();
63
+ /** Record the router's selection for a session. Keeps only the most recent 256 sessions. */
64
+ export function recordRouteSelection(sessionId, result) {
65
+ // Map preserves insertion order. Delete-then-set moves the key to the end,
66
+ // so oldest is always keys().next().
67
+ if (routeSelections.has(sessionId))
68
+ routeSelections.delete(sessionId);
69
+ routeSelections.set(sessionId, result);
70
+ if (routeSelections.size > ROUTE_SELECTION_CAP) {
71
+ const oldest = routeSelections.keys().next().value;
72
+ if (oldest !== undefined)
73
+ routeSelections.delete(oldest);
74
+ }
75
+ }
76
+ /** Retrieve the most recent selection for a session, or undefined. */
77
+ export function getRouteSelection(sessionId) {
78
+ return routeSelections.get(sessionId);
79
+ }
61
80
  //# sourceMappingURL=router.js.map
@@ -8,7 +8,9 @@
8
8
  * - types.ts — shared types
9
9
  */
10
10
  import { DeferredTool } from "../DeferredTool.js";
11
+ import { readOhConfig } from "../harness/config.js";
11
12
  import { getContextWindow } from "../harness/cost.js";
13
+ import { ModelRouter } from "../providers/router.js";
12
14
  import { StreamingToolExecutor } from "../services/StreamingToolExecutor.js";
13
15
  import { toolToAPIFormat } from "../Tool.js";
14
16
  import { createAssistantMessage, createToolResultMessage, createUserMessage } from "../types/message.js";
@@ -18,8 +20,27 @@ import { isNetworkError, isOverloadError, isPromptTooLongError, isRateLimitError
18
20
  import { executeToolCalls } from "./tools.js";
19
21
  export { compressMessages } from "./compress.js";
20
22
  const DEFAULT_MAX_TURNS = 50;
23
+ /** Rough context-usage estimate in [0, 1]. Returns undefined when tokenization is unavailable. */
24
+ function estimateRouteContextUsage(messages, provider, model) {
25
+ const estimate = provider.estimateTokens?.bind(provider);
26
+ if (!estimate)
27
+ return undefined;
28
+ const info = provider.getModelInfo?.(model);
29
+ const window = info?.contextWindow;
30
+ if (!window || window <= 0)
31
+ return undefined;
32
+ let total = 0;
33
+ for (const m of messages) {
34
+ if (typeof m.content === "string")
35
+ total += estimate(m.content);
36
+ // Non-string content (tool calls etc.) is skipped — rough estimate only.
37
+ }
38
+ return Math.min(1, total / window);
39
+ }
21
40
  export async function* query(userMessage, config, existingMessages = []) {
22
41
  const maxTurns = config.maxTurns ?? DEFAULT_MAX_TURNS;
42
+ const routerCfg = readOhConfig()?.modelRouter ?? {};
43
+ const router = new ModelRouter(routerCfg, config.model ?? "");
23
44
  const toolContext = {
24
45
  workingDir: config.workingDir ?? process.cwd(),
25
46
  abortSignal: config.abortSignal,
@@ -160,7 +181,16 @@ export async function* query(userMessage, config, existingMessages = []) {
160
181
  let streamError = null;
161
182
  const streamingExecutor = new StreamingToolExecutor(config.tools, toolContext, config.permissionMode, config.askUser, config.abortSignal);
162
183
  try {
163
- for await (const event of config.provider.stream(state.messages, turnPrompt, apiTools, config.model)) {
184
+ const ctxUsage = estimateRouteContextUsage(state.messages, config.provider, config.model ?? "");
185
+ const selection = router.select({
186
+ turn: state.turn,
187
+ hadToolCalls: state.lastTurnHadTools ?? false,
188
+ toolCallCount: state.lastTurnToolCount ?? 0,
189
+ contextUsage: ctxUsage,
190
+ isFinalResponse: (state.lastTurnHadTools === false || state.lastTurnHadTools === undefined) && state.turn > 1,
191
+ role: config.role,
192
+ });
193
+ for await (const event of config.provider.stream(state.messages, turnPrompt, apiTools, selection.model)) {
164
194
  if (config.abortSignal?.aborted)
165
195
  break;
166
196
  switch (event.type) {
@@ -283,6 +313,8 @@ export async function* query(userMessage, config, existingMessages = []) {
283
313
  if (remaining.length > 0) {
284
314
  yield* executeToolCalls(remaining, config.tools, toolContext, config.permissionMode, config.askUser, state);
285
315
  }
316
+ state.lastTurnHadTools = toolCalls.length > 0;
317
+ state.lastTurnToolCount = toolCalls.length;
286
318
  state.transition = "next_turn";
287
319
  }
288
320
  yield { type: "turn_complete", reason: "max_turns" };