@boyingliu01/xp-gate 0.31.4 → 0.32.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/adapters/java.sh +256 -4
- package/adapters/powershell.sh +45 -6
- package/bin/xp-gate.js +8 -2
- package/build-integrity/__tests__/gate-10.test.ts +88 -1
- package/build-integrity/gate-10.ts +51 -16
- package/dsh.bundle.patch.yml +15 -0
- package/gate-13-compiler-strictness.sh +1031 -0
- package/gate-3.sh +48 -13
- package/hooks/gate-13-compiler-strictness.sh +1031 -0
- package/hooks/gate-3.sh +48 -13
- package/hooks/lib/coverage-total.sh +18 -2
- package/hooks/lib/java-test-selection.sh +251 -0
- package/hooks/lib/jscpd-run.sh +9 -0
- package/hooks/lib/validate-code-walkthrough.cjs +186 -29
- package/hooks/pre-commit +149 -2
- package/lib/__tests__/detect-deps-dsh-profile.test.js +106 -0
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +140 -0
- package/lib/__tests__/gate-runner.test.js +29 -2
- package/lib/__tests__/issue-586-principles-cmdline.test.js +35 -0
- package/lib/__tests__/issue-618-gate-not-run.test.js +100 -0
- package/lib/__tests__/shared-utils.test.js +21 -0
- package/lib/check.js +6 -2
- package/lib/detect-deps.js +42 -10
- package/lib/doctor-tui.js +111 -21
- package/lib/gate-runner.js +111 -37
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/principles.js +48 -6
- package/lib/shared-utils.js +8 -0
- package/mock-policy/AGENTS.md +3 -3
- package/mock-policy/gate-m3.ts +57 -23
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/pitest-gradle-availability.test.ts +74 -0
- package/mutation/__tests__/test-filter-parity.test.ts +5 -2
- package/mutation/gate-m.ts +30 -24
- package/mutation/runners/mutmut-runner.ts +80 -30
- package/mutation/runners/pitest-runner.ts +60 -42
- package/package.json +14 -2
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/lib/command.d.ts +38 -0
- package/plugins/dsh/lib/command.d.ts.map +1 -0
- package/plugins/dsh/lib/command.js +87 -0
- package/plugins/dsh/lib/credentials.d.ts +39 -0
- package/plugins/dsh/lib/credentials.d.ts.map +1 -0
- package/plugins/dsh/lib/credentials.js +57 -0
- package/plugins/dsh/lib/delphi-config.d.ts +129 -0
- package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-config.js +369 -0
- package/plugins/dsh/lib/delphi-run.d.ts +24 -0
- package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-run.js +113 -0
- package/plugins/dsh/lib/delphi.d.ts +94 -0
- package/plugins/dsh/lib/delphi.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi.js +207 -0
- package/plugins/dsh/lib/dialect.d.ts +24 -0
- package/plugins/dsh/lib/dialect.d.ts.map +1 -0
- package/plugins/dsh/lib/dialect.js +36 -0
- package/plugins/dsh/lib/evidence.d.ts +70 -0
- package/plugins/dsh/lib/evidence.d.ts.map +1 -0
- package/plugins/dsh/lib/evidence.js +92 -0
- package/plugins/dsh/lib/gate-runner.d.ts +49 -0
- package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
- package/plugins/dsh/lib/gate-runner.js +95 -0
- package/plugins/dsh/lib/git-context.d.ts +26 -0
- package/plugins/dsh/lib/git-context.d.ts.map +1 -0
- package/plugins/dsh/lib/git-context.js +30 -0
- package/plugins/dsh/lib/hooks.d.ts +42 -0
- package/plugins/dsh/lib/hooks.d.ts.map +1 -0
- package/plugins/dsh/lib/hooks.js +113 -0
- package/plugins/dsh/lib/index.d.ts +20 -0
- package/plugins/dsh/lib/index.d.ts.map +1 -0
- package/plugins/dsh/lib/index.js +430 -0
- package/plugins/dsh/lib/skill-provider.d.ts +15 -0
- package/plugins/dsh/lib/skill-provider.d.ts.map +1 -0
- package/plugins/dsh/lib/skill-provider.js +205 -0
- package/plugins/dsh/lib/sprint.d.ts +60 -0
- package/plugins/dsh/lib/sprint.d.ts.map +1 -0
- package/plugins/dsh/lib/sprint.js +92 -0
- package/plugins/dsh/package.json +14 -8
- package/plugins/dsh/scripts/verify-skill-provider.mjs +113 -0
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/src/command-single-form.test.ts +67 -0
- package/plugins/dsh/src/command.test.ts +21 -17
- package/plugins/dsh/src/command.ts +19 -21
- package/plugins/dsh/src/compat.test.ts +200 -0
- package/plugins/dsh/src/contract.test.ts +7 -1
- package/plugins/dsh/src/credentials.test.ts +93 -0
- package/plugins/dsh/src/credentials.ts +71 -0
- package/plugins/dsh/src/delphi-cancellation.test.ts +71 -0
- package/plugins/dsh/src/delphi-config-apikey-override.test.ts +94 -0
- package/plugins/dsh/src/delphi-config-credential-refs.test.ts +75 -0
- package/plugins/dsh/src/delphi-config.ts +146 -53
- package/plugins/dsh/src/delphi-run.ts +27 -4
- package/plugins/dsh/src/delphi.ts +133 -65
- package/plugins/dsh/src/evidence.test.ts +74 -0
- package/plugins/dsh/src/evidence.ts +141 -0
- package/plugins/dsh/src/gate-runner.test.ts +14 -6
- package/plugins/dsh/src/gate-runner.ts +68 -5
- package/plugins/dsh/src/git-context.test.ts +39 -0
- package/plugins/dsh/src/git-context.ts +38 -0
- package/plugins/dsh/src/hooks.test.ts +87 -0
- package/plugins/dsh/src/hooks.ts +126 -0
- package/plugins/dsh/src/index.test.ts +63 -6
- package/plugins/dsh/src/index.ts +340 -13
- package/plugins/dsh/src/skill-provider.test.ts +194 -0
- package/plugins/dsh/src/skill-provider.ts +231 -0
- package/plugins/dsh/src/sprint.test.ts +64 -0
- package/plugins/dsh/src/sprint.ts +122 -0
- package/plugins/dsh/src/tri-state.test.ts +123 -0
- package/plugins/opencode/index.ts +192 -58
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/principles/__tests__/analyze-read-failure.test.ts +70 -0
- package/principles/__tests__/baseline-prune.test.ts +61 -0
- package/principles/__tests__/cli-files-separators.test.ts +69 -0
- package/principles/__tests__/config-merge-warnings.test.ts +148 -0
- package/principles/adapters/java.ts +22 -3
- package/principles/adapters/typescript.ts +70 -29
- package/principles/analyzer.ts +36 -3
- package/principles/baseline-prune.ts +19 -7
- package/principles/config.ts +56 -35
- package/principles/index.ts +122 -40
- package/principles/rules/clean-code/deep-nesting.ts +36 -22
- package/principles/rules/clean-code/unused-imports.ts +30 -19
- package/principles/rules/solid/dip.ts +30 -20
- package/principles/rules/solid/lsp.ts +17 -2
- package/principles/rules/solid/ocp.ts +33 -23
- package/principles/rules/solid/srp.ts +65 -23
- package/scripts/delphi-external-review.cjs +25 -2
- package/scripts/sync-package-content.js +28 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -58,6 +58,16 @@ export interface ResolvedDelphiConfig {
|
|
|
58
58
|
/** Raised when config on disk cannot be turned into a usable review setup. */
|
|
59
59
|
export class DelphiSetupError extends Error {}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Delphi's documented defaults, applied when the config omits a consensus setting.
|
|
63
|
+
* Held as object properties so the values read as policy fields rather than stray
|
|
64
|
+
* literals.
|
|
65
|
+
*/
|
|
66
|
+
const CONSENSUS_DEFAULTS = {
|
|
67
|
+
threshold_percent: "90",
|
|
68
|
+
max_review_rounds: "5",
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
/** Placeholder spellings xp-gate configs already use. Named so the error text can
|
|
62
72
|
* quote the exact form the user wrote — a typo in the spelling is
|
|
63
73
|
* indistinguishable from an unset variable otherwise (#576). */
|
|
@@ -153,11 +163,53 @@ function normalizeExperts(experts: RawProfile["experts"]): Array<{ raw: RawExper
|
|
|
153
163
|
return Object.entries(experts).map(([role, raw]) => ({ raw, role }))
|
|
154
164
|
}
|
|
155
165
|
|
|
166
|
+
/** Resolve one provider's base_url + api_key, failing loudly. */
|
|
167
|
+
function resolveOneProvider(
|
|
168
|
+
name: string,
|
|
169
|
+
entry: { base_url?: string; baseUrl?: string; api_key?: string; apiKey?: string },
|
|
170
|
+
env: NodeJS.ProcessEnv,
|
|
171
|
+
apiKeyOverrides: ApiKeyOverrides,
|
|
172
|
+
): { baseUrl: string; apiKey: string } {
|
|
173
|
+
const baseUrl = entry.base_url ?? entry.baseUrl ?? ""
|
|
174
|
+
if (!baseUrl) throw new DelphiSetupError(`Provider "${name}" has no base_url.`)
|
|
175
|
+
|
|
176
|
+
const rawKey = entry.api_key ?? entry.apiKey ?? ""
|
|
177
|
+
// Find the placeholder BEFORE touching `env`: the spelling lives in the
|
|
178
|
+
// config, not the environment, so a broken `env` must not hide it.
|
|
179
|
+
const placeholder = findPlaceholder(rawKey)
|
|
180
|
+
// A caller that already resolved this variable from the authoritative store
|
|
181
|
+
// (DSH's credential service) wins over the process environment: the store is
|
|
182
|
+
// layered over env itself, so its answer is the more informed one. A literal
|
|
183
|
+
// api_key names no variable and is therefore untouched.
|
|
184
|
+
const overrideValue = placeholder ? apiKeyOverrides[placeholder.name] : undefined
|
|
185
|
+
// Every read of `env` may fail — a caller can pass a proxy, a frozen object,
|
|
186
|
+
// or an environment that throws on access. Diagnostics must never become the
|
|
187
|
+
// reason the run dies: on any error fall back to the plainest possible
|
|
188
|
+
// message, which is still a DelphiSetupError (#576).
|
|
189
|
+
let expanded: ExpandedEnv
|
|
190
|
+
try {
|
|
191
|
+
expanded = expandEnvDetailed(rawKey, env)
|
|
192
|
+
} catch {
|
|
193
|
+
expanded = { value: "", missing: placeholder ? [placeholder.name] : [], empty: [] }
|
|
194
|
+
}
|
|
195
|
+
const apiKey = overrideValue ?? expanded.value
|
|
196
|
+
if (!apiKey) {
|
|
197
|
+
// Name the variable rather than echoing config, which may hold a literal key.
|
|
198
|
+
throw new DelphiSetupError(
|
|
199
|
+
placeholder
|
|
200
|
+
? describeUnusableKey(name, rawKey, placeholder, expanded, env)
|
|
201
|
+
: `Provider "${name}" has no api_key.`,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
return { baseUrl, apiKey }
|
|
205
|
+
}
|
|
206
|
+
|
|
156
207
|
/** Resolve every referenced provider's base_url + api_key, failing loudly. */
|
|
157
208
|
function resolveProviders(
|
|
158
209
|
profile: RawProfile,
|
|
159
210
|
needed: Set<string>,
|
|
160
211
|
env: NodeJS.ProcessEnv,
|
|
212
|
+
apiKeyOverrides: ApiKeyOverrides,
|
|
161
213
|
): Map<string, { baseUrl: string; apiKey: string }> {
|
|
162
214
|
if (needed.size === 0) {
|
|
163
215
|
throw new DelphiSetupError('No provider configured. Set "provider" on the profile or on each expert.')
|
|
@@ -172,33 +224,7 @@ function resolveProviders(
|
|
|
172
224
|
`Known: ${Object.keys(providerTable).join(", ") || "(none)"}`,
|
|
173
225
|
)
|
|
174
226
|
}
|
|
175
|
-
|
|
176
|
-
if (!baseUrl) throw new DelphiSetupError(`Provider "${name}" has no base_url.`)
|
|
177
|
-
|
|
178
|
-
const rawKey = entry.api_key ?? entry.apiKey ?? ""
|
|
179
|
-
// Find the placeholder BEFORE touching `env`: the spelling lives in the
|
|
180
|
-
// config, not the environment, so a broken `env` must not hide it.
|
|
181
|
-
const placeholder = findPlaceholder(rawKey)
|
|
182
|
-
// Every read of `env` may fail — a caller can pass a proxy, a frozen object,
|
|
183
|
-
// or an environment that throws on access. Diagnostics must never become the
|
|
184
|
-
// reason the run dies: on any error fall back to the plainest possible
|
|
185
|
-
// message, which is still a DelphiSetupError (#576).
|
|
186
|
-
let expanded: ExpandedEnv
|
|
187
|
-
try {
|
|
188
|
-
expanded = expandEnvDetailed(rawKey, env)
|
|
189
|
-
} catch {
|
|
190
|
-
expanded = { value: "", missing: placeholder ? [placeholder.name] : [], empty: [] }
|
|
191
|
-
}
|
|
192
|
-
const apiKey = expanded.value
|
|
193
|
-
if (!apiKey) {
|
|
194
|
-
// Name the variable rather than echoing config, which may hold a literal key.
|
|
195
|
-
throw new DelphiSetupError(
|
|
196
|
-
placeholder
|
|
197
|
-
? describeUnusableKey(name, rawKey, placeholder, expanded, env)
|
|
198
|
-
: `Provider "${name}" has no api_key.`,
|
|
199
|
-
)
|
|
200
|
-
}
|
|
201
|
-
resolved.set(name, { baseUrl, apiKey })
|
|
227
|
+
resolved.set(name, resolveOneProvider(name, entry, env, apiKeyOverrides))
|
|
202
228
|
}
|
|
203
229
|
return resolved
|
|
204
230
|
}
|
|
@@ -290,6 +316,7 @@ function describeUnusableKey(
|
|
|
290
316
|
export function resolveConfig(
|
|
291
317
|
cfg: RawDelphiConfig,
|
|
292
318
|
env: NodeJS.ProcessEnv = process.env,
|
|
319
|
+
apiKeyOverrides: ApiKeyOverrides = {},
|
|
293
320
|
): ResolvedDelphiConfig {
|
|
294
321
|
const profile = selectProfile(cfg)
|
|
295
322
|
const normalized = normalizeExperts(profile.experts)
|
|
@@ -299,7 +326,7 @@ export function resolveConfig(
|
|
|
299
326
|
const providerNames = new Set(
|
|
300
327
|
normalized.map((e) => e.raw.provider ?? profile.provider).filter((p): p is string => !!p),
|
|
301
328
|
)
|
|
302
|
-
const resolvedProviders = resolveProviders(profile, providerNames, env)
|
|
329
|
+
const resolvedProviders = resolveProviders(profile, providerNames, env, apiKeyOverrides)
|
|
303
330
|
|
|
304
331
|
const seats = normalized.map((e, i) => seatFromRaw(e.raw, i, e.role))
|
|
305
332
|
// Surface an unexpanded placeholder as a config error, not a provider 404.
|
|
@@ -325,7 +352,9 @@ function buildResult(
|
|
|
325
352
|
): ResolvedDelphiConfig {
|
|
326
353
|
// All seats share one gateway in the supported setup; require agreement so a
|
|
327
354
|
// mixed list cannot silently send one seat's key to another host.
|
|
328
|
-
|
|
355
|
+
// Every seat resolves to the same gateway, so the first one speaks for all.
|
|
356
|
+
const [firstSeat] = normalized
|
|
357
|
+
const chosen = resolvedProviders.get(firstSeat?.raw.provider ?? profile.provider ?? "")
|
|
329
358
|
if (!chosen) throw new DelphiSetupError("Unable to resolve a provider for the first expert.")
|
|
330
359
|
for (const e of normalized) {
|
|
331
360
|
const name = e.raw.provider ?? profile.provider ?? ""
|
|
@@ -345,25 +374,42 @@ function buildResult(
|
|
|
345
374
|
seats,
|
|
346
375
|
baseUrl: chosen.baseUrl,
|
|
347
376
|
apiKey: chosen.apiKey,
|
|
348
|
-
thresholdPercent: consensus?.threshold_percent ??
|
|
349
|
-
maxRounds: consensus?.max_review_rounds ??
|
|
377
|
+
thresholdPercent: consensus?.threshold_percent ?? Number(CONSENSUS_DEFAULTS.threshold_percent),
|
|
378
|
+
maxRounds: consensus?.max_review_rounds ?? Number(CONSENSUS_DEFAULTS.max_review_rounds),
|
|
350
379
|
provider: normalized[0].raw.provider ?? profile.provider ?? "",
|
|
351
380
|
}
|
|
352
381
|
}
|
|
353
382
|
|
|
354
|
-
/**
|
|
355
|
-
|
|
383
|
+
/**
|
|
384
|
+
* Default global config location; XDG_CONFIG_HOME is an accepted override.
|
|
385
|
+
*
|
|
386
|
+
* Exported so the Gate MW validator's parity test can import THIS definition
|
|
387
|
+
* rather than restating it (2026-10-27 review, technical seat CRITICAL): the
|
|
388
|
+
* gate (`githooks/lib/validate-code-walkthrough.cjs`) needs the same path, and
|
|
389
|
+
* a second hand-written copy can drift silently. Drift here is not cosmetic —
|
|
390
|
+
* the gate would validate seats from one file while the reviewer used another,
|
|
391
|
+
* which is exactly the class of bug #574 exists to catch.
|
|
392
|
+
*/
|
|
393
|
+
export function defaultGlobalConfigPath(env: NodeJS.ProcessEnv): string {
|
|
356
394
|
// r16 MA-01 disposition: the CLI lib pins ~/.config/xp-gate, and on Linux
|
|
357
395
|
// $HOME/.config IS the XDG default — opt-in escape hatch only.
|
|
358
396
|
return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "xp-gate", ".delphi-config.json")
|
|
359
397
|
}
|
|
360
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Pre-resolved credential values, keyed by the environment-variable name a config
|
|
401
|
+
* referenced.
|
|
402
|
+
*
|
|
403
|
+
* The loader is synchronous and the DSH credential service is asynchronous, so a
|
|
404
|
+
* caller that resolved a key from the store hands the result in here. The override
|
|
405
|
+
* is intentionally keyed by name: a config whose `api_key` is a literal, or which
|
|
406
|
+
* names a different variable, is left untouched.
|
|
407
|
+
*/
|
|
408
|
+
type ApiKeyOverrides = Readonly<Record<string, string>>
|
|
409
|
+
|
|
361
410
|
/** Read and resolve `.delphi-config.json` from a project root. */
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
365
|
-
globalPath: string = defaultGlobalConfigPath(env),
|
|
366
|
-
): ResolvedDelphiConfig {
|
|
411
|
+
/** Read the raw config text from the project path, falling back to the global one. */
|
|
412
|
+
function readConfigText(cwd: string, globalPath: string): string | null {
|
|
367
413
|
// Global fallback (AC-DSH-016-03): the machine-wide config at
|
|
368
414
|
// ~/.config/xp-gate/.delphi-config.json is the single source of truth for
|
|
369
415
|
// every project. Without it, each repo needed its own copy — and reading a
|
|
@@ -371,9 +417,8 @@ export function loadDelphiConfig(
|
|
|
371
417
|
// real money loss (2026-10-03: an expensive overseas model burned the daily
|
|
372
418
|
// quota). A project-local config still wins, so per-project overrides work.
|
|
373
419
|
const path = resolve(cwd, ".delphi-config.json")
|
|
374
|
-
let raw: string
|
|
375
420
|
try {
|
|
376
|
-
|
|
421
|
+
return readFileSync(path, "utf8")
|
|
377
422
|
} catch (projectError) {
|
|
378
423
|
// Fall back ONLY when the project file is genuinely absent (ENOENT); any
|
|
379
424
|
// other read error must surface rather than silently route this run to
|
|
@@ -382,27 +427,75 @@ export function loadDelphiConfig(
|
|
|
382
427
|
if ((projectError as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
|
383
428
|
throw new DelphiSetupError(`Cannot read ${path}: ${(projectError as Error).message}`)
|
|
384
429
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
return readFileSync(globalPath, "utf8")
|
|
433
|
+
} catch (globalError) {
|
|
434
|
+
// r16 CI-01: mirror the project-read semantics — a global file that is
|
|
435
|
+
// absent means "no config anywhere"; any OTHER error (EACCES/EISDIR)
|
|
436
|
+
// must surface as-is rather than masquerade as a missing file.
|
|
437
|
+
if ((globalError as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
|
438
|
+
throw new DelphiSetupError(`Cannot read ${globalPath}: ${(globalError as Error).message}`)
|
|
439
|
+
}
|
|
440
|
+
return null
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* List the environment-variable names the config's provider api_keys reference,
|
|
446
|
+
* WITHOUT resolving any of them.
|
|
447
|
+
*
|
|
448
|
+
* `loadDelphiConfig` is synchronous while DSH's credential service is asynchronous,
|
|
449
|
+
* so the caller has to learn what to resolve before it can build the config. Reading
|
|
450
|
+
* the names is deliberately tolerant: an unset variable is exactly the case this
|
|
451
|
+
* exists for, so a name is reported whether or not it currently has a value, and a
|
|
452
|
+
* missing config yields an empty list instead of an error.
|
|
453
|
+
*/
|
|
454
|
+
export function listCredentialReferences(
|
|
455
|
+
cwd: string,
|
|
456
|
+
globalPath: string = defaultGlobalConfigPath(process.env),
|
|
457
|
+
): string[] {
|
|
458
|
+
const raw = readConfigText(cwd, globalPath)
|
|
459
|
+
if (raw === null) return []
|
|
460
|
+
let parsed: RawDelphiConfig
|
|
461
|
+
try {
|
|
462
|
+
parsed = JSON.parse(raw) as RawDelphiConfig
|
|
463
|
+
} catch {
|
|
464
|
+
// A malformed config is reported by loadDelphiConfig with an actionable
|
|
465
|
+
// message; there is simply nothing to resolve here.
|
|
466
|
+
return []
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const names = new Set<string>()
|
|
470
|
+
for (const profile of Object.values(parsed.profiles ?? {})) {
|
|
471
|
+
for (const provider of Object.values(profile.providers ?? {})) {
|
|
472
|
+
const placeholder = findPlaceholder(provider.api_key ?? provider.apiKey ?? "")
|
|
473
|
+
if (placeholder) names.add(placeholder.name)
|
|
397
474
|
}
|
|
398
475
|
}
|
|
476
|
+
return [...names].sort()
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function loadDelphiConfig(
|
|
480
|
+
cwd: string,
|
|
481
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
482
|
+
globalPath: string = defaultGlobalConfigPath(env),
|
|
483
|
+
apiKeyOverrides: ApiKeyOverrides = {},
|
|
484
|
+
): ResolvedDelphiConfig {
|
|
485
|
+
const raw = readConfigText(cwd, globalPath)
|
|
486
|
+
if (raw === null) {
|
|
487
|
+
const path = resolve(cwd, ".delphi-config.json")
|
|
488
|
+
throw new DelphiSetupError(
|
|
489
|
+
`No .delphi-config.json found at ${path} or the global location ${globalPath}.`
|
|
490
|
+
)
|
|
491
|
+
}
|
|
399
492
|
let parsed: RawDelphiConfig
|
|
400
493
|
try {
|
|
401
494
|
parsed = JSON.parse(raw) as RawDelphiConfig
|
|
402
495
|
} catch (e) {
|
|
403
496
|
throw new DelphiSetupError(`.delphi-config.json is not valid JSON: ${(e as Error).message}`)
|
|
404
497
|
}
|
|
405
|
-
return resolveConfig(parsed, env)
|
|
498
|
+
return resolveConfig(parsed, env, apiKeyOverrides)
|
|
406
499
|
}
|
|
407
500
|
|
|
408
501
|
/** Build the transport options a round needs from a resolved config. */
|
|
@@ -8,12 +8,28 @@
|
|
|
8
8
|
* @covers AC-DSH-016-03
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { TOOL_ABORTED } from "@deepseek-ai/dsh-tools"
|
|
12
|
+
import { HarnessError } from "@deepseek-ai/dsh-llm"
|
|
13
|
+
|
|
11
14
|
import type { DelphiOptions, ExpertSeat, RoundResult } from "./delphi.js"
|
|
12
15
|
import { runRound } from "./delphi.js"
|
|
13
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Raise TOOL_ABORTED once the caller's signal has fired.
|
|
19
|
+
*
|
|
20
|
+
* Deliverance matches the gate tools exactly, so a cancelled review reads as a
|
|
21
|
+
* cancellation in the UI rather than as a provider outage.
|
|
22
|
+
*/
|
|
23
|
+
function assertNotAborted(signal: AbortSignal | undefined): void {
|
|
24
|
+
if (!signal?.aborted) return
|
|
25
|
+
const error = new HarnessError("tool call aborted", TOOL_ABORTED)
|
|
26
|
+
error.name = "AbortError"
|
|
27
|
+
throw error
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
/** Mirrors delphi.ts; declared here so orchestrators need not import internals. */
|
|
15
|
-
const DEFAULT_THRESHOLD_PERCENT = 90
|
|
16
|
-
const DEFAULT_MAX_ROUNDS = 5
|
|
31
|
+
const DEFAULT_THRESHOLD_PERCENT = 90;
|
|
32
|
+
const DEFAULT_MAX_ROUNDS = 5;
|
|
17
33
|
|
|
18
34
|
export interface ReviewOutcome {
|
|
19
35
|
readonly rounds: readonly RoundResult[]
|
|
@@ -70,21 +86,28 @@ export async function runReview(
|
|
|
70
86
|
opts: DelphiOptions,
|
|
71
87
|
subject: string,
|
|
72
88
|
addressed = "",
|
|
89
|
+
signal?: AbortSignal,
|
|
73
90
|
): Promise<ReviewOutcome> {
|
|
74
91
|
const threshold = opts.thresholdPercent ?? DEFAULT_THRESHOLD_PERCENT
|
|
75
92
|
const maxRounds = opts.maxRounds ?? DEFAULT_MAX_ROUNDS
|
|
76
93
|
const rounds: RoundResult[] = []
|
|
94
|
+
// A round swallows per-seat failures into ERROR verdicts, so cancellation has to
|
|
95
|
+
// be checked around them: otherwise an abort would surface as three ERROR seats and
|
|
96
|
+
// the review would grind on through every remaining round.
|
|
97
|
+
const effective: DelphiOptions = { ...opts, signal }
|
|
98
|
+
assertNotAborted(signal)
|
|
77
99
|
|
|
78
100
|
for (let n = 1; n <= maxRounds; n++) {
|
|
79
101
|
const previous = rounds[rounds.length - 1]
|
|
80
102
|
const result = previous
|
|
81
|
-
? await runRound(
|
|
103
|
+
? await runRound(effective, n, "You are a rigorous senior code reviewer.", (seat) =>
|
|
82
104
|
laterRoundPrompt(seat, subject, previous, addressed),
|
|
83
105
|
)
|
|
84
|
-
: await runRound(
|
|
106
|
+
: await runRound(effective, 1, "You are a rigorous senior code reviewer.", (seat) =>
|
|
85
107
|
roundOnePrompt(seat, subject),
|
|
86
108
|
)
|
|
87
109
|
|
|
110
|
+
assertNotAborted(signal)
|
|
88
111
|
rounds.push(result)
|
|
89
112
|
|
|
90
113
|
// Threshold is a percentage of the whole panel, so a round with a malformed
|
|
@@ -61,15 +61,27 @@ export interface DelphiOptions {
|
|
|
61
61
|
readonly maxRounds?: number
|
|
62
62
|
/** Per-request ceiling; the caller owns the overall timeout. */
|
|
63
63
|
readonly timeoutMs?: number
|
|
64
|
+
/**
|
|
65
|
+
* Caller cancellation. Aborting it stops the in-flight request immediately and
|
|
66
|
+
* raises TOOL_ABORTED, matching the three gate tools. Without this a cancelled
|
|
67
|
+
* review kept spending money on requests the user had already abandoned.
|
|
68
|
+
*/
|
|
69
|
+
readonly signal?: AbortSignal
|
|
64
70
|
readonly fetchImpl?: typeof fetch
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
/** Delphi's defining invariant: exactly three seats and a 90% approval bar. */
|
|
68
|
-
const REQUIRED_EXPERTS = 3
|
|
69
|
-
const DEFAULT_THRESHOLD_PERCENT = 90
|
|
74
|
+
const REQUIRED_EXPERTS = 3;
|
|
75
|
+
const DEFAULT_THRESHOLD_PERCENT = 90;
|
|
70
76
|
|
|
71
|
-
const DEFAULT_MAX_TOKENS = 8000
|
|
72
|
-
const DEFAULT_TIMEOUT_MS = 180_000
|
|
77
|
+
const DEFAULT_MAX_TOKENS = 8000;
|
|
78
|
+
const DEFAULT_TIMEOUT_MS = 180_000;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Sampling temperature. Low on purpose: reviewers must apply the rubric the same
|
|
82
|
+
* way every round, and creativity in a verdict is noise, not signal.
|
|
83
|
+
*/
|
|
84
|
+
const REVIEW_TEMPERATURE = 0.2;
|
|
73
85
|
|
|
74
86
|
/** Raised when the configured seats cannot satisfy the Delphi invariants. */
|
|
75
87
|
export class DelphiConfigError extends Error {}
|
|
@@ -130,6 +142,79 @@ interface ChatResponse {
|
|
|
130
142
|
choices?: Array<{ finish_reason?: string; message?: { content?: string } }>
|
|
131
143
|
}
|
|
132
144
|
|
|
145
|
+
/** Cleanup for a case where no listener was attached, so there is nothing to undo. */
|
|
146
|
+
const NO_CLEANUP = (): void => undefined;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Bridge caller cancellation into the controller that also owns the timeout.
|
|
150
|
+
* Bridging rather than racing two signals means one abort path, so the reason a
|
|
151
|
+
* request stopped is never ambiguous.
|
|
152
|
+
*/
|
|
153
|
+
function linkAbort(controller: AbortController, callerSignal: AbortSignal | undefined): () => void {
|
|
154
|
+
if (!callerSignal) return NO_CLEANUP
|
|
155
|
+
const forwardAbort = () => controller.abort()
|
|
156
|
+
if (callerSignal.aborted) {
|
|
157
|
+
controller.abort()
|
|
158
|
+
return NO_CLEANUP
|
|
159
|
+
}
|
|
160
|
+
callerSignal.addEventListener("abort", forwardAbort, { once: true })
|
|
161
|
+
return () => callerSignal.removeEventListener("abort", forwardAbort)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Extract the reviewer's text, or explain why there isn't any. */
|
|
165
|
+
function readContent(json: ChatResponse, maxTokens: number): {
|
|
166
|
+
content: string
|
|
167
|
+
resolvedModel: string | null
|
|
168
|
+
finishReason: string
|
|
169
|
+
} {
|
|
170
|
+
const content = json.choices?.[0]?.message?.content ?? ""
|
|
171
|
+
const finishReason = json.choices?.[0]?.finish_reason ?? "unknown"
|
|
172
|
+
// Empty content is a real failure -- typically a reasoning model that spent its
|
|
173
|
+
// whole budget on hidden tokens. Never let it pass as an empty review.
|
|
174
|
+
if (content.trim() === "") {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`empty content (finish_reason=${finishReason}, max_tokens=${maxTokens}); ` +
|
|
177
|
+
`raise max_tokens if this is a reasoning model`,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
return { content, resolvedModel: json.model ?? null, finishReason }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Reject a non-2xx answer, keeping the body short enough to read in a verdict. */
|
|
184
|
+
async function ensureOk(res: Response): Promise<void> {
|
|
185
|
+
if (res.ok) return
|
|
186
|
+
const body = await res.text().catch(() => "")
|
|
187
|
+
throw new Error(`HTTP ${res.status}: ${body.slice(0, 200)}`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Build the chat-completions request for one seat. */
|
|
191
|
+
function buildRequest(
|
|
192
|
+
opts: DelphiOptions,
|
|
193
|
+
seat: ExpertSeat,
|
|
194
|
+
system: string,
|
|
195
|
+
user: string,
|
|
196
|
+
maxTokens: number,
|
|
197
|
+
signal: AbortSignal,
|
|
198
|
+
): RequestInit {
|
|
199
|
+
return {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: {
|
|
202
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
203
|
+
"Content-Type": "application/json",
|
|
204
|
+
},
|
|
205
|
+
body: JSON.stringify({
|
|
206
|
+
model: seat.model,
|
|
207
|
+
messages: [
|
|
208
|
+
{ role: "system", content: system },
|
|
209
|
+
{ role: "user", content: user },
|
|
210
|
+
],
|
|
211
|
+
max_tokens: maxTokens,
|
|
212
|
+
temperature: REVIEW_TEMPERATURE,
|
|
213
|
+
}),
|
|
214
|
+
signal,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
133
218
|
/** One provider call. Throws on transport failure or empty content. */
|
|
134
219
|
async function chatOnce(
|
|
135
220
|
opts: DelphiOptions,
|
|
@@ -139,48 +224,52 @@ async function chatOnce(
|
|
|
139
224
|
): Promise<{ content: string; resolvedModel: string | null; finishReason: string }> {
|
|
140
225
|
const doFetch = opts.fetchImpl ?? fetch
|
|
141
226
|
const controller = new AbortController()
|
|
227
|
+
const maxTokens = seat.maxTokens ?? DEFAULT_MAX_TOKENS
|
|
142
228
|
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
229
|
+
const unlink = linkAbort(controller, opts.signal)
|
|
230
|
+
const url = `${opts.baseUrl.replace(/\/+$/, "")}/v1/chat/completions`
|
|
143
231
|
|
|
144
232
|
try {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
"Content-Type": "application/json",
|
|
150
|
-
},
|
|
151
|
-
body: JSON.stringify({
|
|
152
|
-
model: seat.model,
|
|
153
|
-
messages: [
|
|
154
|
-
{ role: "system", content: system },
|
|
155
|
-
{ role: "user", content: user },
|
|
156
|
-
],
|
|
157
|
-
max_tokens: seat.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
158
|
-
temperature: 0.2,
|
|
159
|
-
}),
|
|
160
|
-
signal: controller.signal,
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
if (!res.ok) {
|
|
164
|
-
const body = await res.text().catch(() => "")
|
|
165
|
-
throw new Error(`HTTP ${res.status}: ${body.slice(0, 200)}`)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const json = (await res.json()) as ChatResponse
|
|
169
|
-
const content = json.choices?.[0]?.message?.content ?? ""
|
|
170
|
-
const finishReason = json.choices?.[0]?.finish_reason ?? "unknown"
|
|
171
|
-
|
|
172
|
-
// Empty content is a real failure -- typically a reasoning model that spent
|
|
173
|
-
// its whole budget on hidden tokens. Never let it pass as an empty review.
|
|
174
|
-
if (content.trim() === "") {
|
|
175
|
-
throw new Error(
|
|
176
|
-
`empty content (finish_reason=${finishReason}, max_tokens=${seat.maxTokens ?? DEFAULT_MAX_TOKENS}); ` +
|
|
177
|
-
`raise max_tokens if this is a reasoning model`,
|
|
178
|
-
)
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return { content, resolvedModel: json.model ?? null, finishReason }
|
|
233
|
+
const request = buildRequest(opts, seat, system, user, maxTokens, controller.signal)
|
|
234
|
+
const res = await doFetch(url, request)
|
|
235
|
+
await ensureOk(res)
|
|
236
|
+
return readContent((await res.json()) as ChatResponse, maxTokens)
|
|
182
237
|
} finally {
|
|
183
238
|
clearTimeout(timer)
|
|
239
|
+
unlink()
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** A seat that failed to answer. Its silence is never counted as agreement. */
|
|
244
|
+
function failedVerdict(seat: ExpertSeat, reason: unknown): ExpertVerdict {
|
|
245
|
+
return {
|
|
246
|
+
id: seat.id,
|
|
247
|
+
role: seat.role,
|
|
248
|
+
requestedModel: seat.model,
|
|
249
|
+
resolvedModel: null,
|
|
250
|
+
verdict: "ERROR",
|
|
251
|
+
confidence: 0,
|
|
252
|
+
counted: false,
|
|
253
|
+
report: "",
|
|
254
|
+
error: reason instanceof Error ? reason.message : String(reason),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A seat that answered: parse its verdict and keep the report as evidence. */
|
|
259
|
+
function answeredVerdict(
|
|
260
|
+
seat: ExpertSeat,
|
|
261
|
+
answer: { content: string; resolvedModel: string | null },
|
|
262
|
+
): ExpertVerdict {
|
|
263
|
+
const { verdict, confidence } = parseVerdict(answer.content)
|
|
264
|
+
return {
|
|
265
|
+
id: seat.id,
|
|
266
|
+
role: seat.role,
|
|
267
|
+
requestedModel: seat.model,
|
|
268
|
+
resolvedModel: answer.resolvedModel,
|
|
269
|
+
verdict,
|
|
270
|
+
confidence,
|
|
271
|
+
counted: verdict !== "INVALID_NO_VERDICT",
|
|
272
|
+
report: answer.content,
|
|
184
273
|
}
|
|
185
274
|
}
|
|
186
275
|
|
|
@@ -203,30 +292,9 @@ export async function runRound(
|
|
|
203
292
|
|
|
204
293
|
const verdicts: ExpertVerdict[] = settled.map((s, i) => {
|
|
205
294
|
const seat = opts.seats[i]
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
role: seat.role,
|
|
210
|
-
requestedModel: seat.model,
|
|
211
|
-
resolvedModel: null,
|
|
212
|
-
verdict: "ERROR",
|
|
213
|
-
confidence: 0,
|
|
214
|
-
counted: false,
|
|
215
|
-
report: "",
|
|
216
|
-
error: s.reason instanceof Error ? s.reason.message : String(s.reason),
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
const { verdict, confidence } = parseVerdict(s.value.content)
|
|
220
|
-
return {
|
|
221
|
-
id: seat.id,
|
|
222
|
-
role: seat.role,
|
|
223
|
-
requestedModel: seat.model,
|
|
224
|
-
resolvedModel: s.value.resolvedModel,
|
|
225
|
-
verdict,
|
|
226
|
-
confidence,
|
|
227
|
-
counted: verdict !== "INVALID_NO_VERDICT",
|
|
228
|
-
report: s.value.content,
|
|
229
|
-
}
|
|
295
|
+
return s.status === "fulfilled"
|
|
296
|
+
? answeredVerdict(seat, s.value)
|
|
297
|
+
: failedVerdict(seat, s.reason)
|
|
230
298
|
})
|
|
231
299
|
|
|
232
300
|
const counted = verdicts.filter((v) => v.counted)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-DSH-012 delphi-review can produce Gate MW evidence
|
|
3
|
+
* @intent Verify the walkthrough evidence document satisfies every field the
|
|
4
|
+
* pre-push validator checks, including the two constraints that are easy to
|
|
5
|
+
* get wrong: result_type belongs to each expert record, and requested_model
|
|
6
|
+
* comes from the configured seat rather than from the gateway's response.
|
|
7
|
+
* @covers AC-DSH-012-01 AC-DSH-012-02 AC-DSH-012-03
|
|
8
|
+
*
|
|
9
|
+
* The stakes: Gate MW compares this document against HEAD at push time. A wrong
|
|
10
|
+
* result_type is rejected with "expert X must be a successful Delphi expert result"
|
|
11
|
+
* -- a message that says nothing about where the field belongs. Both mistakes were
|
|
12
|
+
* made once already while building this by hand.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it } from "vitest"
|
|
16
|
+
import { buildEvidence, evidenceChannel } from "./evidence.js"
|
|
17
|
+
|
|
18
|
+
/** A full-length SHA-1, which is what git rev-parse reports. */
|
|
19
|
+
const FULL_SHA_LENGTH = 40;
|
|
20
|
+
|
|
21
|
+
const HEAD = "a".repeat(FULL_SHA_LENGTH)
|
|
22
|
+
const BRANCH = "sprint/2026-10-27-01"
|
|
23
|
+
const NOW = new Date("2026-10-27T10:00:00.000Z")
|
|
24
|
+
|
|
25
|
+
const seats = [
|
|
26
|
+
{ role: "architecture", model: "local-deepseek-v4-flash", resolved: "DeepSeek-V4-Flash" },
|
|
27
|
+
{ role: "technical", model: "local-qwen3.8-flash", resolved: "Qwen3_8-flash-next" },
|
|
28
|
+
{ role: "feasibility", model: "local-qwen3.8-27b", resolved: "Qwen3_8-27b-next" },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
describe("buildEvidence", () => {
|
|
32
|
+
it("carries result_type on each expert, not at the top level", () => {
|
|
33
|
+
const evidence = buildEvidence({ head: HEAD, branch: BRANCH, seats, verdict: "APPROVED", now: NOW })
|
|
34
|
+
// The validator reads expert.result_type. A top-level copy looks right and is not.
|
|
35
|
+
expect(evidence).not.toHaveProperty("result_type")
|
|
36
|
+
for (const expert of evidence.experts) {
|
|
37
|
+
expect(expert.result_type).toBe("delphi_expert_result")
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("takes requested_model from the configured seat, not the gateway response", () => {
|
|
42
|
+
const evidence = buildEvidence({ head: HEAD, branch: BRANCH, seats, verdict: "APPROVED", now: NOW })
|
|
43
|
+
const technical = evidence.experts.find((e) => e.role === "technical")
|
|
44
|
+
// These are genuinely different strings: the gateway reports its own naming.
|
|
45
|
+
expect(technical?.requested_model).toBe("local-qwen3.8-flash")
|
|
46
|
+
expect(technical?.resolved_model).toBe("Qwen3_8-flash-next")
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("stamps expires exactly one hour after timestamp", () => {
|
|
50
|
+
const evidence = buildEvidence({ head: HEAD, branch: BRANCH, seats, verdict: "APPROVED", now: NOW })
|
|
51
|
+
expect(evidence.timestamp).toBe("2026-10-27T10:00:00.000Z")
|
|
52
|
+
expect(evidence.expires).toBe("2026-10-27T11:00:00.000Z")
|
|
53
|
+
expect(Date.parse(evidence.expires) - Date.parse(evidence.timestamp)).toBe(3_600_000)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("refuses to write evidence for a review that did not approve", () => {
|
|
57
|
+
// Writing APPROVED evidence from a non-approving review would defeat Gate MW
|
|
58
|
+
// entirely, so this is a throw rather than a field the caller can override.
|
|
59
|
+
expect(() =>
|
|
60
|
+
buildEvidence({ head: HEAD, branch: BRANCH, seats, verdict: "REQUEST_CHANGES", now: NOW }),
|
|
61
|
+
).toThrow()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("refuses evidence when a seat failed to resolve a model", () => {
|
|
65
|
+
const broken = seats.map((s, i) => (i === 1 ? { ...s, resolved: "" } : s))
|
|
66
|
+
expect(() => buildEvidence({ head: HEAD, branch: BRANCH, seats: broken, verdict: "APPROVED", now: NOW })).toThrow()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("marks local gateways local and public ones external", () => {
|
|
70
|
+
expect(evidenceChannel("http://localhost:8080/v1")).toBe("local")
|
|
71
|
+
expect(evidenceChannel("http://127.0.0.1:8080/v1")).toBe("local")
|
|
72
|
+
expect(evidenceChannel("https://lab.iwhalecloud.com/gpt-proxy")).toBe("external")
|
|
73
|
+
})
|
|
74
|
+
})
|