@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/README.md +2 -0
- package/dist/commands/info.js +20 -0
- package/dist/commands/session.js +39 -12
- package/dist/harness/config.d.ts +7 -0
- package/dist/harness/hooks.d.ts +35 -1
- package/dist/harness/hooks.js +204 -35
- package/dist/harness/session.d.ts +4 -0
- package/dist/harness/session.js +2 -0
- package/dist/harness/submit-handler.js +36 -2
- package/dist/main.js +48 -24
- package/dist/mcp/oauth-keychain.d.ts +16 -0
- package/dist/mcp/oauth-keychain.js +70 -0
- package/dist/mcp/oauth-storage-fs.d.ts +23 -0
- package/dist/mcp/oauth-storage-fs.js +58 -0
- package/dist/mcp/oauth-storage.d.ts +24 -19
- package/dist/mcp/oauth-storage.js +46 -49
- package/dist/providers/fallback.js +19 -7
- package/dist/providers/index.js +18 -2
- package/dist/providers/router.d.ts +4 -0
- package/dist/providers/router.js +19 -0
- package/dist/query/index.js +33 -1
- package/dist/query/tools.js +49 -11
- package/dist/query/types.d.ts +6 -0
- package/dist/tools/AgentTool/index.js +2 -2
- package/dist/tools/ScheduleWakeupTool/index.d.ts +2 -2
- package/package.json +5 -2
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
|
|
279
|
+
// Auto-detect provider or launch the setup wizard
|
|
280
280
|
let provider;
|
|
281
281
|
let resolvedModel;
|
|
282
|
-
|
|
282
|
+
const tryCreateProvider = async () => {
|
|
283
283
|
const { createProvider } = await import("./providers/index.js");
|
|
284
284
|
const overrides = {};
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
await
|
|
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
|
-
/**
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Delete credentials from BOTH keychain and filesystem. Idempotent.
|
|
49
|
+
*/
|
|
47
50
|
export async function deleteCredentials(storageDir, name) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
39
|
+
hasYielded = true;
|
|
40
40
|
yield event;
|
|
41
41
|
}
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
package/dist/providers/index.js
CHANGED
|
@@ -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
|
|
33
|
-
|
|
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
|
package/dist/providers/router.js
CHANGED
|
@@ -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
|
package/dist/query/index.js
CHANGED
|
@@ -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
|
-
|
|
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" };
|