@oh-my-pi/pi-coding-agent 15.5.6 → 15.5.8
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/CHANGELOG.md +72 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
- package/dist/types/commands/auth-gateway.d.ts +3 -0
- package/dist/types/config/settings-schema.d.ts +60 -12
- package/dist/types/edit/file-snapshot-store.d.ts +9 -6
- package/dist/types/edit/hashline/diff.d.ts +4 -5
- package/dist/types/edit/streaming.d.ts +2 -1
- package/dist/types/eval/py/index.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
- package/dist/types/extensibility/shared-events.d.ts +1 -1
- package/dist/types/internal-urls/index.d.ts +1 -0
- package/dist/types/internal-urls/vault-protocol.d.ts +93 -0
- package/dist/types/lib/xai-http.d.ts +40 -0
- package/dist/types/mcp/transports/http.d.ts +9 -0
- package/dist/types/modes/components/tool-execution.d.ts +2 -1
- package/dist/types/session/agent-session.d.ts +4 -1
- package/dist/types/tools/fetch.d.ts +16 -0
- package/dist/types/tools/image-gen.d.ts +6 -2
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/match-line-format.d.ts +2 -2
- package/dist/types/tools/plan-mode-guard.d.ts +5 -6
- package/dist/types/tools/render-utils.d.ts +3 -1
- package/dist/types/tools/tts.d.ts +18 -0
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/utils/file-mentions.d.ts +2 -0
- package/package.json +8 -8
- package/src/cli/args.ts +2 -0
- package/src/cli/auth-broker-cli.ts +2 -1
- package/src/cli/auth-gateway-cli.ts +210 -9
- package/src/commands/auth-gateway.ts +7 -1
- package/src/config/model-registry.ts +41 -9
- package/src/config/settings-schema.ts +55 -13
- package/src/edit/file-snapshot-store.ts +9 -6
- package/src/edit/hashline/diff.ts +26 -13
- package/src/edit/hashline/execute.ts +13 -9
- package/src/edit/renderer.ts +9 -9
- package/src/edit/streaming.ts +4 -6
- package/src/eval/py/index.ts +1 -1
- package/src/extensibility/custom-tools/types.ts +1 -1
- package/src/extensibility/shared-events.ts +1 -1
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/router.ts +2 -0
- package/src/internal-urls/vault-protocol.ts +936 -0
- package/src/lib/xai-http.ts +124 -0
- package/src/main.ts +1 -2
- package/src/mcp/transports/http.ts +29 -2
- package/src/modes/components/tool-execution.ts +6 -4
- package/src/modes/controllers/event-controller.ts +10 -3
- package/src/modes/controllers/selector-controller.ts +7 -2
- package/src/modes/interactive-mode.ts +11 -3
- package/src/modes/utils/ui-helpers.ts +2 -1
- package/src/prompts/system/system-prompt.md +3 -0
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/prompts/tools/ast-grep.md +1 -1
- package/src/prompts/tools/read.md +3 -3
- package/src/prompts/tools/search.md +1 -1
- package/src/sdk.ts +41 -10
- package/src/session/agent-session.ts +112 -14
- package/src/system-prompt.ts +2 -0
- package/src/tools/ast-edit.ts +10 -7
- package/src/tools/ast-grep.ts +12 -11
- package/src/tools/eval.ts +28 -3
- package/src/tools/fetch.ts +52 -24
- package/src/tools/image-gen.ts +205 -7
- package/src/tools/index.ts +1 -0
- package/src/tools/match-line-format.ts +2 -2
- package/src/tools/path-utils.ts +2 -0
- package/src/tools/plan-mode-guard.ts +20 -7
- package/src/tools/read.ts +70 -55
- package/src/tools/render-utils.ts +15 -0
- package/src/tools/search.ts +14 -14
- package/src/tools/tts.ts +133 -0
- package/src/tools/write.ts +61 -6
- package/src/utils/file-mentions.ts +11 -5
- package/src/web/search/providers/codex.ts +2 -1
|
@@ -2,12 +2,11 @@ import type { ToolSession } from ".";
|
|
|
2
2
|
/**
|
|
3
3
|
* Resolve a write/edit target to its absolute filesystem path.
|
|
4
4
|
*
|
|
5
|
-
* In plan mode, transparently redirects
|
|
6
|
-
* plan file's basename
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* file the plan-mode guard would otherwise reject.
|
|
5
|
+
* In plan mode, transparently redirects `PLAN.md` aliases and targets whose
|
|
6
|
+
* basename matches the plan file's basename to the canonical plan file
|
|
7
|
+
* location at `state.planFilePath`. This lets `write` and `edit` accept the
|
|
8
|
+
* habitual plan filename after approval even when the active artifact has a
|
|
9
|
+
* titled path such as `local://APPROVED.md`.
|
|
11
10
|
*
|
|
12
11
|
* Outside plan mode (or when the basename does not match) this is a no-op.
|
|
13
12
|
*/
|
|
@@ -100,7 +100,9 @@ export interface DiffStats {
|
|
|
100
100
|
}
|
|
101
101
|
export declare function getDiffStats(diffText: string): DiffStats;
|
|
102
102
|
export declare function formatDiffStats(added: number, removed: number, hunks: number, theme: Theme): string;
|
|
103
|
-
export declare function truncateDiffByHunk(diffText: string, maxHunks: number, maxLines: number
|
|
103
|
+
export declare function truncateDiffByHunk(diffText: string, maxHunks: number, maxLines: number, options?: {
|
|
104
|
+
fromTail?: boolean;
|
|
105
|
+
}): {
|
|
104
106
|
text: string;
|
|
105
107
|
hiddenHunks: number;
|
|
106
108
|
hiddenLines: number;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import type { CustomTool } from "../extensibility/custom-tools/types";
|
|
3
|
+
type TtsCodec = "mp3" | "wav";
|
|
4
|
+
declare const ttsSchema: z.ZodObject<{
|
|
5
|
+
text: z.ZodString;
|
|
6
|
+
voice_id: z.ZodDefault<z.ZodString>;
|
|
7
|
+
language: z.ZodDefault<z.ZodString>;
|
|
8
|
+
output_path: z.ZodString;
|
|
9
|
+
sample_rate: z.ZodOptional<z.ZodNumber>;
|
|
10
|
+
bit_rate: z.ZodOptional<z.ZodNumber>;
|
|
11
|
+
}, z.core.$strip>;
|
|
12
|
+
interface TtsToolDetails {
|
|
13
|
+
bytes: number;
|
|
14
|
+
voiceId: string;
|
|
15
|
+
codec: TtsCodec;
|
|
16
|
+
}
|
|
17
|
+
export declare const ttsTool: CustomTool<typeof ttsSchema, TtsToolDetails>;
|
|
18
|
+
export {};
|
|
@@ -15,6 +15,8 @@ export type WriteToolInput = z.infer<typeof writeSchema>;
|
|
|
15
15
|
export interface WriteToolDetails {
|
|
16
16
|
diagnostics?: FileDiagnosticsResult;
|
|
17
17
|
meta?: OutputMeta;
|
|
18
|
+
/** Set when the file was auto-chmod'd because content begins with a `#!` shebang. */
|
|
19
|
+
madeExecutable?: boolean;
|
|
18
20
|
}
|
|
19
21
|
type WriteParams = WriteToolInput;
|
|
20
22
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SnapshotStore } from "@oh-my-pi/hashline";
|
|
1
2
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
2
3
|
/** Extract all @filepath mentions from text */
|
|
3
4
|
export declare function extractFileMentions(text: string): string[];
|
|
@@ -8,4 +9,5 @@ export declare function extractFileMentions(text: string): string[];
|
|
|
8
9
|
export declare function generateFileMentionMessages(filePaths: string[], cwd: string, options?: {
|
|
9
10
|
autoResizeImages?: boolean;
|
|
10
11
|
useHashLines?: boolean;
|
|
12
|
+
snapshotStore?: SnapshotStore;
|
|
11
13
|
}): Promise<AgentMessage[]>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "15.5.
|
|
4
|
+
"version": "15.5.8",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -47,13 +47,13 @@
|
|
|
47
47
|
"@agentclientprotocol/sdk": "0.21.0",
|
|
48
48
|
"@babel/parser": "^7.29.3",
|
|
49
49
|
"@mozilla/readability": "^0.6.0",
|
|
50
|
-
"@oh-my-pi/hashline": "15.5.
|
|
51
|
-
"@oh-my-pi/omp-stats": "15.5.
|
|
52
|
-
"@oh-my-pi/pi-agent-core": "15.5.
|
|
53
|
-
"@oh-my-pi/pi-ai": "15.5.
|
|
54
|
-
"@oh-my-pi/pi-natives": "15.5.
|
|
55
|
-
"@oh-my-pi/pi-tui": "15.5.
|
|
56
|
-
"@oh-my-pi/pi-utils": "15.5.
|
|
50
|
+
"@oh-my-pi/hashline": "15.5.8",
|
|
51
|
+
"@oh-my-pi/omp-stats": "15.5.8",
|
|
52
|
+
"@oh-my-pi/pi-agent-core": "15.5.8",
|
|
53
|
+
"@oh-my-pi/pi-ai": "15.5.8",
|
|
54
|
+
"@oh-my-pi/pi-natives": "15.5.8",
|
|
55
|
+
"@oh-my-pi/pi-tui": "15.5.8",
|
|
56
|
+
"@oh-my-pi/pi-utils": "15.5.8",
|
|
57
57
|
"@puppeteer/browsers": "^2.13.0",
|
|
58
58
|
"@types/turndown": "5.0.6",
|
|
59
59
|
"@xterm/headless": "^6.0.0",
|
package/src/cli/args.ts
CHANGED
|
@@ -247,6 +247,8 @@ export function getExtraHelpText(): string {
|
|
|
247
247
|
OPENCODE_API_KEY - OpenCode Zen/OpenCode Go models
|
|
248
248
|
CURSOR_ACCESS_TOKEN - Cursor AI models
|
|
249
249
|
AI_GATEWAY_API_KEY - Vercel AI Gateway
|
|
250
|
+
WAFER_PASS_API_KEY - Wafer Pass (flat-rate subscription; GLM-5.1, Qwen3.5)
|
|
251
|
+
WAFER_SERVERLESS_API_KEY - Wafer Serverless (pay-as-you-go)
|
|
250
252
|
|
|
251
253
|
${chalk.dim("# Cloud Providers")}
|
|
252
254
|
AWS_PROFILE - AWS Bedrock (or AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY)
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
startAuthBroker,
|
|
35
35
|
} from "@oh-my-pi/pi-ai";
|
|
36
36
|
import { $which, APP_NAME, getAgentDbPath, getConfigRootDir, isEnoent, logger, VERSION } from "@oh-my-pi/pi-utils";
|
|
37
|
+
import { setTransports as setLoggerTransports } from "@oh-my-pi/pi-utils/logger";
|
|
37
38
|
import { $ } from "bun";
|
|
38
39
|
import chalk from "chalk";
|
|
39
40
|
import { resolveAuthBrokerConfig } from "../session/auth-broker-config";
|
|
@@ -124,7 +125,7 @@ async function runServe(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
|
|
|
124
125
|
// The broker is a long-running headless service: route structured logs to
|
|
125
126
|
// stdout so a process supervisor (pm2, journald, k8s) captures them, and
|
|
126
127
|
// skip the rotating ~/.omp/logs/ file the TUI default would have used.
|
|
127
|
-
|
|
128
|
+
setLoggerTransports({ console: true, file: false });
|
|
128
129
|
|
|
129
130
|
const bind = flags.bind ?? DEFAULT_AUTH_BROKER_BIND;
|
|
130
131
|
const token = await ensureToken();
|
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
type Api,
|
|
20
20
|
AuthBrokerClient,
|
|
21
21
|
AuthStorage,
|
|
22
|
+
type CompletionProbe,
|
|
23
|
+
type CompletionProbeInput,
|
|
24
|
+
type CredentialCompletionResult,
|
|
25
|
+
completeSimple,
|
|
22
26
|
DEFAULT_AUTH_GATEWAY_BIND,
|
|
23
27
|
type GeneratedProvider,
|
|
24
28
|
getBundledModels,
|
|
@@ -46,6 +50,14 @@ export interface AuthGatewayCommandArgs {
|
|
|
46
50
|
* to wire token-paste plumbing into every local client.
|
|
47
51
|
*/
|
|
48
52
|
noAuth?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Strict mode for `check` — additionally exercise every credential
|
|
55
|
+
* against its provider's chat-completion endpoint. The usage probe (run
|
|
56
|
+
* unconditionally) can pass while the chat endpoint still 401s the same
|
|
57
|
+
* bearer, so strict mode is the definitive "is this credential
|
|
58
|
+
* actually usable" signal. Slower and consumes a tiny amount of quota.
|
|
59
|
+
*/
|
|
60
|
+
strict?: boolean;
|
|
49
61
|
};
|
|
50
62
|
}
|
|
51
63
|
|
|
@@ -342,12 +354,185 @@ export async function runAuthGatewayCommand(cmd: AuthGatewayCommandArgs): Promis
|
|
|
342
354
|
}
|
|
343
355
|
}
|
|
344
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Providers whose chat endpoint expects a JSON-serialized credential blob
|
|
359
|
+
* (`{ token, projectId, refreshToken, expiresAt, … }`) rather than the raw
|
|
360
|
+
* access token. Mirrors `getOAuthApiKey` in `packages/ai/src/utils/oauth`.
|
|
361
|
+
*/
|
|
362
|
+
const STRUCTURED_API_KEY_PROVIDERS: ReadonlySet<string> = new Set([
|
|
363
|
+
"github-copilot",
|
|
364
|
+
"google-gemini-cli",
|
|
365
|
+
"google-antigravity",
|
|
366
|
+
]);
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Provider API types that strict-mode chat probes intentionally skip:
|
|
370
|
+
* - `bedrock-converse-stream` resolves credentials from the AWS env/profile, not the broker bearer.
|
|
371
|
+
* - `google-vertex` uses Application Default Credentials; the broker bearer is not the right key.
|
|
372
|
+
* - `cursor-agent` and `pi-native` (gateway forwarding) have transport quirks
|
|
373
|
+
* that make a bearer-only "ping" a poor signal.
|
|
374
|
+
*/
|
|
375
|
+
const STRICT_PROBE_SKIPPED_APIS: ReadonlySet<Api> = new Set<Api>([
|
|
376
|
+
"bedrock-converse-stream",
|
|
377
|
+
"google-vertex",
|
|
378
|
+
"cursor-agent",
|
|
379
|
+
]);
|
|
380
|
+
|
|
381
|
+
/** Max chat models to try per credential before reporting failure. */
|
|
382
|
+
const STRICT_PROBE_MAX_CANDIDATES = 4;
|
|
383
|
+
|
|
384
|
+
/** Per-attempt deadline. Each candidate gets its own slice instead of sharing one budget. */
|
|
385
|
+
const STRICT_PROBE_PER_ATTEMPT_TIMEOUT_MS = 15_000;
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Overall per-credential budget passed to {@link AuthStorage.checkCredentials}.
|
|
389
|
+
* Big enough to walk every candidate at the per-attempt cap with a small
|
|
390
|
+
* margin for refresh/network overhead.
|
|
391
|
+
*/
|
|
392
|
+
const STRICT_PROBE_OVERALL_TIMEOUT_MS = STRICT_PROBE_PER_ATTEMPT_TIMEOUT_MS * (STRICT_PROBE_MAX_CANDIDATES + 1);
|
|
393
|
+
|
|
394
|
+
/** Match upstream errors that mean "this model is gone, try a different one" so we walk the catalog instead of declaring the credential bad. */
|
|
395
|
+
const RETRYABLE_MODEL_ERROR_RE =
|
|
396
|
+
/not[_ -]found|invalid[_ -]model|model[_ -]is[_ -]not[_ -]valid|no longer supported|deprecated|404|decommissioned/i;
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Rank bundled models for a provider in probe order: cheapest first, then by
|
|
400
|
+
* id for determinism. Filters out non-bearer-auth APIs (Vertex/Bedrock),
|
|
401
|
+
* pi-native transport (would loop through the gateway), and placeholder /
|
|
402
|
+
* router entries with negative/missing cost.
|
|
403
|
+
*/
|
|
404
|
+
function pickProbeCandidates(provider: string): Model<Api>[] {
|
|
405
|
+
const bundled = getBundledModels(provider as GeneratedProvider);
|
|
406
|
+
if (bundled.length === 0) return [];
|
|
407
|
+
const candidates = bundled.filter(model => {
|
|
408
|
+
if (model.transport === "pi-native") return false;
|
|
409
|
+
if (STRICT_PROBE_SKIPPED_APIS.has(model.api)) return false;
|
|
410
|
+
if (!model.input.includes("text")) return false;
|
|
411
|
+
const totalCost = (model.cost?.input ?? 0) + (model.cost?.output ?? 0);
|
|
412
|
+
if (!Number.isFinite(totalCost) || totalCost < 0) return false;
|
|
413
|
+
if (model.maxTokens <= 0) return false;
|
|
414
|
+
return true;
|
|
415
|
+
});
|
|
416
|
+
candidates.sort((a, b) => a.cost.input + a.cost.output - (b.cost.input + b.cost.output) || a.id.localeCompare(b.id));
|
|
417
|
+
return candidates;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Compose the apiKey bytes a provider's chat endpoint expects, given a
|
|
422
|
+
* post-refresh probe credential. Mirrors `getOAuthApiKey` for the providers
|
|
423
|
+
* that require a structured blob; otherwise returns the raw access token /
|
|
424
|
+
* API key.
|
|
425
|
+
*/
|
|
426
|
+
function composeProbeApiKey(provider: string, credential: CompletionProbeInput["credential"]): string {
|
|
427
|
+
if (credential.type === "api_key") return credential.apiKey;
|
|
428
|
+
if (!STRUCTURED_API_KEY_PROVIDERS.has(provider)) return credential.accessToken;
|
|
429
|
+
return JSON.stringify({
|
|
430
|
+
token: credential.accessToken,
|
|
431
|
+
enterpriseUrl: credential.enterpriseUrl,
|
|
432
|
+
projectId: credential.projectId,
|
|
433
|
+
refreshToken: credential.refreshToken,
|
|
434
|
+
expiresAt: credential.expiresAt,
|
|
435
|
+
email: credential.email,
|
|
436
|
+
accountId: credential.accountId,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function probeOneModel(
|
|
441
|
+
model: Model<Api>,
|
|
442
|
+
apiKey: string,
|
|
443
|
+
outerSignal: AbortSignal,
|
|
444
|
+
): Promise<CredentialCompletionResult> {
|
|
445
|
+
const start = Date.now();
|
|
446
|
+
const attemptTimeoutSignal = AbortSignal.timeout(STRICT_PROBE_PER_ATTEMPT_TIMEOUT_MS);
|
|
447
|
+
const attemptSignal = AbortSignal.any([outerSignal, attemptTimeoutSignal]);
|
|
448
|
+
// `systemPrompt` is mandatory for some providers (Codex 400s "Instructions
|
|
449
|
+
// are required" without it). `disableReasoning` is intentionally NOT set:
|
|
450
|
+
// providers like Fireworks reject the "none" effort it maps to, and we'd
|
|
451
|
+
// rather burn 16 reasoning tokens than misdiagnose a healthy credential.
|
|
452
|
+
const response = await completeSimple(
|
|
453
|
+
model,
|
|
454
|
+
{
|
|
455
|
+
systemPrompt: ["Connectivity check. Reply with the single word 'pong'."],
|
|
456
|
+
messages: [{ role: "user", content: "ping", timestamp: start }],
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
apiKey,
|
|
460
|
+
maxTokens: 32,
|
|
461
|
+
signal: attemptSignal,
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
const latencyMs = Date.now() - start;
|
|
465
|
+
if (response.stopReason === "error" || response.stopReason === "aborted") {
|
|
466
|
+
return {
|
|
467
|
+
ok: false,
|
|
468
|
+
reason: response.errorMessage ?? `chat probe ended with stopReason=${response.stopReason}`,
|
|
469
|
+
modelId: model.id,
|
|
470
|
+
latencyMs,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
return { ok: true, modelId: model.id, latencyMs };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Build the {@link CompletionProbe} consumed by
|
|
478
|
+
* {@link AuthStorage.checkCredentials} in `--strict` mode. Walks the cheapest
|
|
479
|
+
* candidates per provider, retrying on "model not found / invalid model"
|
|
480
|
+
* errors so a stale catalog entry doesn't masquerade as a bad credential.
|
|
481
|
+
* Stops as soon as one model returns a successful response (the credential
|
|
482
|
+
* authenticated against at least one model in the catalog).
|
|
483
|
+
*/
|
|
484
|
+
function createStrictCompletionProbe(): CompletionProbe {
|
|
485
|
+
return async (input: CompletionProbeInput): Promise<CredentialCompletionResult> => {
|
|
486
|
+
const candidates = pickProbeCandidates(input.provider).slice(0, STRICT_PROBE_MAX_CANDIDATES);
|
|
487
|
+
if (candidates.length === 0) {
|
|
488
|
+
return { ok: null, reason: `no bearer-compatible probe model bundled for provider ${input.provider}` };
|
|
489
|
+
}
|
|
490
|
+
const apiKey = composeProbeApiKey(input.provider, input.credential);
|
|
491
|
+
let lastFailure: CredentialCompletionResult | undefined;
|
|
492
|
+
for (const model of candidates) {
|
|
493
|
+
if (input.signal.aborted) {
|
|
494
|
+
return {
|
|
495
|
+
ok: false,
|
|
496
|
+
reason: "aborted",
|
|
497
|
+
modelId: model.id,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
const result = await probeOneModel(model, apiKey, input.signal);
|
|
501
|
+
if (result.ok === true) return result;
|
|
502
|
+
lastFailure = result;
|
|
503
|
+
if (!RETRYABLE_MODEL_ERROR_RE.test(result.reason ?? "")) {
|
|
504
|
+
// Non-model error (401, 403, 5xx, network) — the credential is the
|
|
505
|
+
// issue, not the catalog. Stop walking.
|
|
506
|
+
return result;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return (
|
|
510
|
+
lastFailure ?? {
|
|
511
|
+
ok: false,
|
|
512
|
+
reason: `all ${candidates.length} probe models failed for provider ${input.provider}`,
|
|
513
|
+
}
|
|
514
|
+
);
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function formatCompletionStatus(completion: CredentialCompletionResult | undefined): string {
|
|
519
|
+
if (!completion) return "";
|
|
520
|
+
if (completion.ok === true) return chalk.green(" [chat: ok]");
|
|
521
|
+
if (completion.ok === false) return chalk.red(" [chat: FAIL]");
|
|
522
|
+
return chalk.yellow(" [chat: skip]");
|
|
523
|
+
}
|
|
524
|
+
|
|
345
525
|
/**
|
|
346
526
|
* `omp auth-gateway check` — probe each broker-supplied credential and print
|
|
347
527
|
* per-credential auth health. Use this when the gateway is returning 401s and
|
|
348
528
|
* you need to find which row in a multi-account pool is the bad one. The
|
|
349
529
|
* aggregate `/v1/usage` endpoint silently drops failed credentials, so a
|
|
350
530
|
* dedicated diagnostic is the only way to see which credentials failed.
|
|
531
|
+
*
|
|
532
|
+
* Strict mode (`--strict`) additionally exercises each credential against a
|
|
533
|
+
* cheap chat model from its provider's bundled catalog. This catches the case
|
|
534
|
+
* where the usage endpoint reports 200 but the chat endpoint 401s the same
|
|
535
|
+
* bearer (revoked OAuth scope, mislabeled provider row, etc).
|
|
351
536
|
*/
|
|
352
537
|
async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
|
|
353
538
|
const brokerConfig = await resolveAuthBrokerConfig();
|
|
@@ -363,10 +548,16 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
|
|
|
363
548
|
const storage = new AuthStorage(store, { sourceLabel: `broker ${brokerConfig.url}` });
|
|
364
549
|
try {
|
|
365
550
|
await storage.reload();
|
|
366
|
-
const results = await storage.checkCredentials(
|
|
551
|
+
const results = await storage.checkCredentials(
|
|
552
|
+
flags.strict
|
|
553
|
+
? { completionProbe: createStrictCompletionProbe(), completionTimeoutMs: STRICT_PROBE_OVERALL_TIMEOUT_MS }
|
|
554
|
+
: undefined,
|
|
555
|
+
);
|
|
367
556
|
|
|
368
557
|
if (flags.json) {
|
|
369
|
-
process.stdout.write(
|
|
558
|
+
process.stdout.write(
|
|
559
|
+
`${JSON.stringify({ broker: brokerConfig.url, strict: flags.strict === true, credentials: results }, null, 2)}\n`,
|
|
560
|
+
);
|
|
370
561
|
} else {
|
|
371
562
|
const grouped = new Map<string, typeof results>();
|
|
372
563
|
for (const row of results) {
|
|
@@ -375,7 +566,7 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
|
|
|
375
566
|
grouped.set(row.provider, list);
|
|
376
567
|
}
|
|
377
568
|
const providers = [...grouped.keys()].sort();
|
|
378
|
-
process.stdout.write(`broker: ${brokerConfig.url}\n`);
|
|
569
|
+
process.stdout.write(`broker: ${brokerConfig.url}${flags.strict ? chalk.dim(" [strict]") : ""}\n`);
|
|
379
570
|
for (const provider of providers) {
|
|
380
571
|
const rows = grouped.get(provider) ?? [];
|
|
381
572
|
process.stdout.write(`\n${chalk.bold(provider)} (${rows.length})\n`);
|
|
@@ -389,19 +580,29 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
|
|
|
389
580
|
const identity =
|
|
390
581
|
row.email ?? row.accountId ?? (row.type === "api_key" ? "(api key)" : "(no identity on credential)");
|
|
391
582
|
const remote = row.remoteRefresh ? chalk.dim(" [remote-refresh]") : "";
|
|
392
|
-
const
|
|
583
|
+
const reasonParts: string[] = [];
|
|
584
|
+
if (row.reason) reasonParts.push(row.reason);
|
|
585
|
+
if (row.completion?.reason) reasonParts.push(`chat: ${row.completion.reason}`);
|
|
586
|
+
const reason = reasonParts.length > 0 ? chalk.dim(` — ${reasonParts.join("; ")}`) : "";
|
|
587
|
+
const chat = formatCompletionStatus(row.completion);
|
|
393
588
|
process.stdout.write(
|
|
394
|
-
` ${status} id=${row.id.toString().padStart(3)} ${row.type.padEnd(7)} ${identity}${remote}${reason}\n`,
|
|
589
|
+
` ${status}${chat} id=${row.id.toString().padStart(3)} ${row.type.padEnd(7)} ${identity}${remote}${reason}\n`,
|
|
395
590
|
);
|
|
396
591
|
}
|
|
397
592
|
}
|
|
398
593
|
const failed = results.filter(row => row.ok === false).length;
|
|
399
594
|
const unverifiable = results.filter(row => row.ok === null).length;
|
|
400
595
|
const passing = results.filter(row => row.ok === true).length;
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
596
|
+
const chatFailed = flags.strict ? results.filter(row => row.completion?.ok === false).length : 0;
|
|
597
|
+
const summaryParts = [
|
|
598
|
+
chalk.green(`${passing} ok`),
|
|
599
|
+
chalk.red(`${failed} failed`),
|
|
600
|
+
chalk.yellow(`${unverifiable} unverifiable`),
|
|
601
|
+
];
|
|
602
|
+
if (flags.strict) summaryParts.push(chalk.red(`${chatFailed} chat-failed`));
|
|
603
|
+
summaryParts.push(`${results.length} total`);
|
|
604
|
+
process.stdout.write(`\n${summaryParts.join(", ")}\n`);
|
|
605
|
+
if (failed > 0 || chatFailed > 0) process.exitCode = 1;
|
|
405
606
|
}
|
|
406
607
|
} finally {
|
|
407
608
|
storage.close();
|
|
@@ -22,13 +22,17 @@ export default class AuthGateway extends Command {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
static flags = {
|
|
25
|
-
json: Flags.boolean({ description: "Output JSON (token/status)" }),
|
|
25
|
+
json: Flags.boolean({ description: "Output JSON (token/status/check)" }),
|
|
26
26
|
bind: Flags.string({ description: "Bind address for `serve` (host:port)", char: "b" }),
|
|
27
27
|
regenerate: Flags.boolean({ description: "Regenerate the gateway bearer token (token)" }),
|
|
28
28
|
"no-auth": Flags.boolean({
|
|
29
29
|
description:
|
|
30
30
|
"Disable inbound bearer-token auth (serve). Useful when bound to loopback — any caller is allowed.",
|
|
31
31
|
}),
|
|
32
|
+
strict: Flags.boolean({
|
|
33
|
+
description:
|
|
34
|
+
"For `check`: additionally probe each credential against its provider's chat-completion endpoint. Slower; consumes a tiny amount of quota per credential.",
|
|
35
|
+
}),
|
|
32
36
|
};
|
|
33
37
|
|
|
34
38
|
static examples = [
|
|
@@ -40,6 +44,7 @@ export default class AuthGateway extends Command {
|
|
|
40
44
|
"# Show local gateway + broker config status\n omp auth-gateway status",
|
|
41
45
|
"# Probe each broker credential to see which one is producing 401s\n omp auth-gateway check",
|
|
42
46
|
"# Same, machine-readable for scripts\n omp auth-gateway check --json",
|
|
47
|
+
"# Strict check — also exercises each credential with a real chat-completion ping\n omp auth-gateway check --strict",
|
|
43
48
|
];
|
|
44
49
|
|
|
45
50
|
async run(): Promise<void> {
|
|
@@ -55,6 +60,7 @@ export default class AuthGateway extends Command {
|
|
|
55
60
|
bind: flags.bind,
|
|
56
61
|
regenerate: flags.regenerate,
|
|
57
62
|
noAuth: flags["no-auth"],
|
|
63
|
+
strict: flags.strict,
|
|
58
64
|
},
|
|
59
65
|
};
|
|
60
66
|
await initTheme();
|
|
@@ -291,6 +291,12 @@ export function mergeDiscoveredModel<TApi extends Api>(
|
|
|
291
291
|
return model;
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
const AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS = new Set<string>(
|
|
295
|
+
PROVIDER_DESCRIPTORS.filter(descriptor => descriptor.dynamicModelsAuthoritative).map(
|
|
296
|
+
descriptor => descriptor.providerId,
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
|
|
294
300
|
function isAuthoritativeProjectCatalogModel(model: Model<Api>): boolean {
|
|
295
301
|
return (
|
|
296
302
|
model.provider === "google-vertex" &&
|
|
@@ -323,6 +329,11 @@ interface DiscoveryProviderConfig {
|
|
|
323
329
|
optional?: boolean;
|
|
324
330
|
}
|
|
325
331
|
|
|
332
|
+
interface BuiltInDiscoveryResult {
|
|
333
|
+
models: Model<Api>[];
|
|
334
|
+
authoritativeProviders: Set<string>;
|
|
335
|
+
}
|
|
336
|
+
|
|
326
337
|
export type ProviderDiscoveryStatus = "idle" | "ok" | "empty" | "cached" | "unavailable" | "unauthenticated";
|
|
327
338
|
|
|
328
339
|
export interface ProviderDiscoveryState {
|
|
@@ -914,6 +925,11 @@ export class ModelRegistry {
|
|
|
914
925
|
cachedAuthoritativeProviders.add(provider);
|
|
915
926
|
}
|
|
916
927
|
}
|
|
928
|
+
for (const provider of cachedStandardResult.authoritativeFreshProviders) {
|
|
929
|
+
if (AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS.has(provider)) {
|
|
930
|
+
cachedAuthoritativeProviders.add(provider);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
917
933
|
if (cachedAuthoritativeProviders.size > 0) {
|
|
918
934
|
builtInModels = dropProviderModels(builtInModels, cachedAuthoritativeProviders);
|
|
919
935
|
}
|
|
@@ -1253,12 +1269,12 @@ export class ModelRegistry {
|
|
|
1253
1269
|
: Promise.all(
|
|
1254
1270
|
selectedDiscoverableProviders.map(provider => this.#discoverProviderModels(provider, strategy)),
|
|
1255
1271
|
).then(results => results.flat());
|
|
1256
|
-
const [configuredDiscovered,
|
|
1272
|
+
const [configuredDiscovered, builtInDiscovery] = await Promise.all([
|
|
1257
1273
|
configuredDiscoveriesPromise,
|
|
1258
1274
|
this.#discoverBuiltInProviderModels(strategy, providerFilter),
|
|
1259
1275
|
]);
|
|
1260
|
-
const discovered = [...configuredDiscovered, ...
|
|
1261
|
-
if (discovered.length === 0) {
|
|
1276
|
+
const discovered = [...configuredDiscovered, ...builtInDiscovery.models];
|
|
1277
|
+
if (discovered.length === 0 && builtInDiscovery.authoritativeProviders.size === 0) {
|
|
1262
1278
|
return;
|
|
1263
1279
|
}
|
|
1264
1280
|
const discoveredModels = this.#applyHardcodedModelPolicies(
|
|
@@ -1271,6 +1287,9 @@ export class ModelRegistry {
|
|
|
1271
1287
|
),
|
|
1272
1288
|
);
|
|
1273
1289
|
const authoritativeProviders = providersWithAuthoritativeProjectCatalog(discoveredModels);
|
|
1290
|
+
for (const provider of builtInDiscovery.authoritativeProviders) {
|
|
1291
|
+
authoritativeProviders.add(provider);
|
|
1292
|
+
}
|
|
1274
1293
|
const baseModels =
|
|
1275
1294
|
authoritativeProviders.size > 0 ? dropProviderModels(this.#models, authoritativeProviders) : this.#models;
|
|
1276
1295
|
const resolved = this.#mergeResolvedModels(baseModels, discoveredModels);
|
|
@@ -1385,7 +1404,7 @@ export class ModelRegistry {
|
|
|
1385
1404
|
async #discoverBuiltInProviderModels(
|
|
1386
1405
|
strategy: ModelRefreshStrategy,
|
|
1387
1406
|
providerFilter?: ReadonlySet<string>,
|
|
1388
|
-
): Promise<
|
|
1407
|
+
): Promise<BuiltInDiscoveryResult> {
|
|
1389
1408
|
// Skip providers already handled by configured discovery (e.g. user-configured ollama with discovery.type)
|
|
1390
1409
|
const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(p => p.provider));
|
|
1391
1410
|
const managerOptions = (await this.#collectBuiltInModelManagerOptions()).filter(opts => {
|
|
@@ -1395,12 +1414,20 @@ export class ModelRegistry {
|
|
|
1395
1414
|
return providerFilter ? providerFilter.has(opts.providerId) : true;
|
|
1396
1415
|
});
|
|
1397
1416
|
if (managerOptions.length === 0) {
|
|
1398
|
-
return [];
|
|
1417
|
+
return { models: [], authoritativeProviders: new Set() };
|
|
1399
1418
|
}
|
|
1400
1419
|
const discoveries = await Promise.all(
|
|
1401
1420
|
managerOptions.map(options => this.#discoverWithModelManager(options, strategy)),
|
|
1402
1421
|
);
|
|
1403
|
-
|
|
1422
|
+
const authoritativeProviders = new Set<string>();
|
|
1423
|
+
const models: Model<Api>[] = [];
|
|
1424
|
+
for (const discovery of discoveries) {
|
|
1425
|
+
models.push(...discovery.models);
|
|
1426
|
+
for (const provider of discovery.authoritativeProviders) {
|
|
1427
|
+
authoritativeProviders.add(provider);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
return { models, authoritativeProviders };
|
|
1404
1431
|
}
|
|
1405
1432
|
|
|
1406
1433
|
async #collectBuiltInModelManagerOptions(): Promise<ModelManagerOptions<Api>[]> {
|
|
@@ -1482,19 +1509,24 @@ export class ModelRegistry {
|
|
|
1482
1509
|
async #discoverWithModelManager(
|
|
1483
1510
|
options: ModelManagerOptions<Api>,
|
|
1484
1511
|
strategy: ModelRefreshStrategy,
|
|
1485
|
-
): Promise<
|
|
1512
|
+
): Promise<BuiltInDiscoveryResult> {
|
|
1486
1513
|
try {
|
|
1487
1514
|
const manager = createModelManager({ ...options, cacheDbPath: this.#cacheDbPath });
|
|
1488
1515
|
const result = await manager.refresh(strategy);
|
|
1489
|
-
|
|
1516
|
+
const models = result.models.map(model =>
|
|
1490
1517
|
model.provider === options.providerId ? model : { ...model, provider: options.providerId },
|
|
1491
1518
|
);
|
|
1519
|
+
const authoritativeProviders = new Set<string>();
|
|
1520
|
+
if (options.dynamicModelsAuthoritative && !result.stale) {
|
|
1521
|
+
authoritativeProviders.add(options.providerId);
|
|
1522
|
+
}
|
|
1523
|
+
return { models, authoritativeProviders };
|
|
1492
1524
|
} catch (error) {
|
|
1493
1525
|
logger.warn("model discovery failed for provider", {
|
|
1494
1526
|
provider: options.providerId,
|
|
1495
1527
|
error: error instanceof Error ? error.message : String(error),
|
|
1496
1528
|
});
|
|
1497
|
-
return [];
|
|
1529
|
+
return { models: [], authoritativeProviders: new Set() };
|
|
1498
1530
|
}
|
|
1499
1531
|
}
|
|
1500
1532
|
|