@circuitwall/jarela 2.2.0 → 2.2.1
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/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +2 -2
- package/.next/standalone/.next/build-manifest.json +2 -2
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/v1/models/[name]/route.js +61 -20
- package/.next/standalone/.next/server/app/api/v1/models/route.js +5 -3
- package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +27 -24
- package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page.js +313 -251
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app-paths-manifest.json +2 -2
- package/.next/standalone/.next/server/chunks/2471.js +17 -17
- package/.next/standalone/.next/server/chunks/2563.js +24 -50
- package/.next/standalone/.next/server/chunks/3399.js +10695 -10695
- package/.next/standalone/.next/server/chunks/3524.js +5 -33
- package/.next/standalone/.next/server/chunks/424.js +10 -5
- package/.next/standalone/.next/server/chunks/4508.js +2 -2
- package/.next/standalone/.next/server/chunks/46.js +111 -46
- package/.next/standalone/.next/server/chunks/466.js +88 -13
- package/.next/standalone/.next/server/chunks/6649.js +1 -1
- package/.next/standalone/.next/server/chunks/9590.js +108 -46
- package/.next/standalone/.next/server/chunks/9640.js +3 -6
- package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
- package/.next/standalone/.next/server/middleware.js +104 -45
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/preview-props.json +3 -3
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/{1395-f201e3e2e1f57715.js → 1395-a340fb0a345382a4.js} +89 -14
- package/.next/standalone/.next/static/chunks/{4576-13f7546a145a3624.js → 7045-8bc2f14e430ca661.js} +58 -2
- package/.next/standalone/.next/static/chunks/app/{page-c6e972a7fd13dae8.js → page-7017f07f8a7a083c.js} +170 -151
- package/.next/standalone/package.json +1 -1
- package/CHANGELOG.md +8 -0
- package/api/client.models.test.ts +31 -0
- package/api/client.ts +16 -1
- package/api/types.ts +8 -0
- package/app/api/v1/models/[name]/route.test.ts +89 -0
- package/app/api/v1/models/[name]/route.ts +54 -3
- package/app/api/v1/models/route.ts +4 -2
- package/app/api/v1/providers/[provider]/models/route.test.ts +53 -0
- package/app/api/v1/providers/[provider]/models/route.ts +27 -16
- package/components/agents/agent-editor/AdvancedSection.tsx +8 -2
- package/components/agents/agent-editor/ModelSection.tsx +6 -3
- package/components/models/ModelEditor.tsx +1 -1
- package/components/models/ModelsPanel.tsx +34 -8
- package/components/models/model-editor/GitHubCopilotAuth.tsx +1 -1
- package/components/models/model-editor/save-helpers.ts +2 -2
- package/components/models/model-editor/useModelEditorForm.ts +1 -1
- package/components/models/model-editor/useModelSaveHandlers.ts +1 -1
- package/components/setup/wizard/StepCredentials.tsx +1 -1
- package/components/setup/wizard/StepModel.tsx +3 -8
- package/hooks/useModels.ts +17 -1
- package/lib/agents/effective-model.test.ts +45 -0
- package/lib/agents/effective-model.ts +11 -5
- package/lib/agents/llm.ts +2 -2
- package/lib/agents/run-thread.ts +10 -5
- package/lib/agents/warm-summary-background.test.ts +1 -2
- package/lib/agents/warm-summary-background.ts +4 -7
- package/lib/db/migrations.ts +10 -0
- package/lib/health/probes.test.ts +8 -11
- package/lib/health/probes.ts +5 -31
- package/lib/models/editor-payload.ts +1 -1
- package/lib/providers/github-copilot-auth.ts +2 -3
- package/lib/providers/github-copilot.test.ts +27 -0
- package/lib/providers/github-copilot.ts +21 -44
- package/lib/stores/integrations.test.ts +7 -0
- package/lib/stores/integrations.ts +4 -4
- package/lib/stores/model-config.test.ts +88 -0
- package/lib/stores/model-config.ts +90 -8
- package/package.json +1 -1
- /package/.next/standalone/.next/static/{e2NV1wk6mWg3SIeELXevg → NUrC9xax1sp1Q70nlFqS6}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{e2NV1wk6mWg3SIeELXevg → NUrC9xax1sp1Q70nlFqS6}/_ssgManifest.js +0 -0
package/lib/agents/run-thread.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { startScheduler } from "@/lib/scheduler";
|
|
|
22
22
|
import { recall, type RecalledMemory } from "@/lib/embeddings";
|
|
23
23
|
import { getMemoryPolicy } from "@/lib/stores/app-settings";
|
|
24
24
|
import { validateAssistantOutput } from "@/lib/agents/output-validator";
|
|
25
|
-
import { getDefaultModelConfig, getModelConfig, getModelParams,
|
|
25
|
+
import { getDefaultModelConfig, getModelConfig, getModelParams, isModelConfigEnabled, listEnabledModelConfigs } from "@/lib/stores/model-config";
|
|
26
26
|
import {
|
|
27
27
|
buildHistoryWindow,
|
|
28
28
|
buildSystemPrompt,
|
|
@@ -425,9 +425,11 @@ export function snapshotThreadModelConfigName(thread_id: string): string | null
|
|
|
425
425
|
const agentCfg = getAgentConfig(thread.agent_id);
|
|
426
426
|
if (!agentCfg) return null;
|
|
427
427
|
|
|
428
|
-
if (agentCfg.model_config_name
|
|
428
|
+
if (agentCfg.model_config_name && isModelConfigEnabled(getModelConfig(agentCfg.model_config_name))) {
|
|
429
|
+
return agentCfg.model_config_name;
|
|
430
|
+
}
|
|
429
431
|
if (getConfig().modelRouterMode !== "off") return null;
|
|
430
|
-
return
|
|
432
|
+
return getDefaultModelConfig()?.name ?? null;
|
|
431
433
|
}
|
|
432
434
|
|
|
433
435
|
export function appendHistoryMessage(
|
|
@@ -578,7 +580,9 @@ export async function prepareThreadRun(req: ThreadRunRequest): Promise<PreparedT
|
|
|
578
580
|
let modelConfigName = req._pinned_model_config_name
|
|
579
581
|
?? agentCfg.model_config_name
|
|
580
582
|
?? null;
|
|
581
|
-
|
|
583
|
+
// Missing or disabled configs fall through to routing / the default; a
|
|
584
|
+
// disabled agent override is kept so re-enabling the model restores it.
|
|
585
|
+
if (modelConfigName && !isModelConfigEnabled(getModelConfig(modelConfigName))) {
|
|
582
586
|
modelConfigName = null;
|
|
583
587
|
}
|
|
584
588
|
let routeDecision: RouteDecisionMetadata | null = null;
|
|
@@ -607,13 +611,14 @@ export async function prepareThreadRun(req: ThreadRunRequest): Promise<PreparedT
|
|
|
607
611
|
if (!modelConfigName && useRouter) {
|
|
608
612
|
const pricingTables = getPricingTables();
|
|
609
613
|
const routed = routeTurnModel({
|
|
610
|
-
models:
|
|
614
|
+
models: listEnabledModelConfigs().map((cfg) => ({
|
|
611
615
|
name: cfg.name,
|
|
612
616
|
provider: cfg.provider,
|
|
613
617
|
model_id: cfg.model_id,
|
|
614
618
|
params: getModelParams(cfg),
|
|
615
619
|
credential_id: cfg.credential_id ?? null,
|
|
616
620
|
is_default: cfg.is_default === 1,
|
|
621
|
+
enabled: true,
|
|
617
622
|
created_at: cfg.created_at,
|
|
618
623
|
updated_at: cfg.updated_at,
|
|
619
624
|
})),
|
|
@@ -71,8 +71,7 @@ vi.mock("@/lib/stores/agent-configs", () => ({
|
|
|
71
71
|
getAgentTierProportions: () => null,
|
|
72
72
|
}));
|
|
73
73
|
vi.mock("@/lib/stores/model-config", () => ({
|
|
74
|
-
|
|
75
|
-
getModelConfig: () => ({ provider: "mock", model_id: "mock-1" }),
|
|
74
|
+
resolveRunnableModelConfig: () => ({ name: "model-1", provider: "mock", model_id: "mock-1" }),
|
|
76
75
|
getModelParams: () => ({}),
|
|
77
76
|
}));
|
|
78
77
|
vi.mock("@/lib/providers", () => ({ getProvider: () => ({}) }));
|
|
@@ -3,7 +3,7 @@ import type { ModelProvider, ProviderParams } from "@/lib/providers/types";
|
|
|
3
3
|
import { summarizeTranscript, transcriptText, extractTopicSegments, validateTopicSegments, type SummaryTopicSegment } from "@/lib/agents/conversation-summary";
|
|
4
4
|
import { takeRecentWarmMessages, warmSummaryMessageLimit, wrapWarmSummary, AUTOMATION_CHANNEL_ORDER } from "@/lib/agents/prepare/history-window";
|
|
5
5
|
import { getAgentConfig, getAgentTierProportions } from "@/lib/stores/agent-configs";
|
|
6
|
-
import {
|
|
6
|
+
import { getModelParams, resolveRunnableModelConfig } from "@/lib/stores/model-config";
|
|
7
7
|
import { putMemory } from "@/lib/stores/memory";
|
|
8
8
|
import {
|
|
9
9
|
commitThreadWarmContext,
|
|
@@ -168,8 +168,7 @@ export async function findTopicBoundary(threadId: string, boundarySeq: number):
|
|
|
168
168
|
if (!thread) return null;
|
|
169
169
|
const agent = getAgentConfig(thread.agent_id);
|
|
170
170
|
if (!agent) return null;
|
|
171
|
-
const
|
|
172
|
-
const modelCfg = modelName ? getModelConfig(modelName) : null;
|
|
171
|
+
const modelCfg = resolveRunnableModelConfig(agent.model_config_name);
|
|
173
172
|
if (!modelCfg?.provider || !modelCfg.model_id) return null;
|
|
174
173
|
|
|
175
174
|
const rows = getRecentMessagesWindow(threadId, 0, undefined, "foreground")
|
|
@@ -339,8 +338,7 @@ async function buildSummaryBefore(
|
|
|
339
338
|
const agent = getAgentConfig(thread.agent_id);
|
|
340
339
|
if (!agent) return null;
|
|
341
340
|
|
|
342
|
-
const
|
|
343
|
-
const modelCfg = modelName ? getModelConfig(modelName) : null;
|
|
341
|
+
const modelCfg = resolveRunnableModelConfig(agent.model_config_name);
|
|
344
342
|
if (!modelCfg?.provider || !modelCfg.model_id) return null;
|
|
345
343
|
|
|
346
344
|
const baseParams = providerOverride?.params ?? getModelParams(modelCfg);
|
|
@@ -491,8 +489,7 @@ async function buildAutomationChannelSummaryBefore(
|
|
|
491
489
|
const agent = getAgentConfig(thread.agent_id);
|
|
492
490
|
if (!agent) return null;
|
|
493
491
|
|
|
494
|
-
const
|
|
495
|
-
const modelCfg = modelName ? getModelConfig(modelName) : null;
|
|
492
|
+
const modelCfg = resolveRunnableModelConfig(agent.model_config_name);
|
|
496
493
|
if (!modelCfg?.provider || !modelCfg.model_id) return null;
|
|
497
494
|
|
|
498
495
|
const baseParams = providerOverride?.params ?? getModelParams(modelCfg);
|
package/lib/db/migrations.ts
CHANGED
|
@@ -469,6 +469,7 @@ export function runMigrations(db: DatabaseSync): void {
|
|
|
469
469
|
ensureCredentialsTable(db);
|
|
470
470
|
ensureCredentialsLabelAndDefaultColumns(db);
|
|
471
471
|
ensureModelConfigCredentialIdColumn(db);
|
|
472
|
+
ensureModelConfigEnabledColumn(db);
|
|
472
473
|
ensureAgentConfigsToolCredentialsColumn(db);
|
|
473
474
|
seedAgentConfigs(db);
|
|
474
475
|
backfillDeveloperInteractiveTerminalTools(db);
|
|
@@ -1553,6 +1554,15 @@ function ensureModelConfigCredentialIdColumn(db: DatabaseSync): void {
|
|
|
1553
1554
|
}
|
|
1554
1555
|
}
|
|
1555
1556
|
|
|
1557
|
+
// ADR-0102: disabled model configs stay saved but are hidden from model
|
|
1558
|
+
// selection, routing, and default fallback. Existing rows default to enabled.
|
|
1559
|
+
function ensureModelConfigEnabledColumn(db: DatabaseSync): void {
|
|
1560
|
+
const cols = db.prepare("PRAGMA table_info(model_configs)").all() as Array<{ name: string }>;
|
|
1561
|
+
if (!cols.some((c) => c.name === "enabled")) {
|
|
1562
|
+
db.exec("ALTER TABLE model_configs ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1");
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1556
1566
|
// Idempotent backfill: for each model_config still carrying an inline
|
|
1557
1567
|
// `api_key` (and no `credential_id` yet), create a credential row and
|
|
1558
1568
|
// link by id. Strips the api_key / base_url / extra_headers fields from
|
|
@@ -562,17 +562,14 @@ describe("health probes", () => {
|
|
|
562
562
|
expect(r.status).toBe("auth_failed");
|
|
563
563
|
expect(String(r.error)).toMatch(/subscription/i);
|
|
564
564
|
});
|
|
565
|
-
it("PAT
|
|
566
|
-
saveIntegration("github-copilot", { api_key: "
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
expect(
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
saveIntegration("github-copilot", { api_key: "ghp_bad" });
|
|
574
|
-
mockFetch(() => new Response("", { status: 401 }));
|
|
575
|
-
expect((await probeGithubCopilot()).status).toBe("auth_failed");
|
|
565
|
+
it("rejects legacy PAT credentials without probing GitHub Models", async () => {
|
|
566
|
+
saveIntegration("github-copilot", { api_key: "ghp_synthetic" });
|
|
567
|
+
const fetch = vi.fn();
|
|
568
|
+
vi.stubGlobal("fetch", fetch);
|
|
569
|
+
const result = await probeGithubCopilot();
|
|
570
|
+
expect(result.status).toBe("auth_failed");
|
|
571
|
+
expect(result.error).toMatch(/device-flow sign-in/i);
|
|
572
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
576
573
|
});
|
|
577
574
|
it("prefers OAuth token over PAT when both are present", async () => {
|
|
578
575
|
storeOAuthToken("gho_xxx");
|
package/lib/health/probes.ts
CHANGED
|
@@ -410,13 +410,8 @@ export async function probeCohere(): Promise<HealthResult> {
|
|
|
410
410
|
}
|
|
411
411
|
|
|
412
412
|
export async function probeGithubCopilot(): Promise<HealthResult> {
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
// "github-copilot-auth" by the in-app sign-in). Verified by
|
|
416
|
-
// exchanging it for a Copilot session token.
|
|
417
|
-
// 2. Personal Access Token saved via the Integrations panel.
|
|
418
|
-
// PATs cannot be exchanged, so we check them against the GitHub
|
|
419
|
-
// Models REST surface which Copilot PATs are routed to.
|
|
413
|
+
// Device-flow OAuth token stored under the github-copilot-auth namespace.
|
|
414
|
+
// Legacy PAT credentials used GitHub Models, which has been retired.
|
|
420
415
|
const oauth = getStoredCopilotOAuthToken();
|
|
421
416
|
if (oauth) {
|
|
422
417
|
try {
|
|
@@ -441,30 +436,9 @@ export async function probeGithubCopilot(): Promise<HealthResult> {
|
|
|
441
436
|
return transient(describeError(err));
|
|
442
437
|
}
|
|
443
438
|
}
|
|
444
|
-
const
|
|
445
|
-
if (!
|
|
446
|
-
|
|
447
|
-
const res = await fetch("https://models.github.ai/catalog/models", {
|
|
448
|
-
headers: {
|
|
449
|
-
Authorization: `Bearer ${pat}`,
|
|
450
|
-
Accept: "application/vnd.github+json",
|
|
451
|
-
"X-GitHub-Api-Version": "2026-03-10",
|
|
452
|
-
"User-Agent": "Jarela",
|
|
453
|
-
},
|
|
454
|
-
signal: probeSignal(DEFAULT_PROBE_TIMEOUT_MS),
|
|
455
|
-
});
|
|
456
|
-
if (res.status === 401 || res.status === 403) {
|
|
457
|
-
return authFailed(`GitHub rejected the PAT (${res.status}). Confirm the token has the "copilot" scope and your subscription is active.`);
|
|
458
|
-
}
|
|
459
|
-
if (res.status === 429) return transient("GitHub rate-limited the probe (429).");
|
|
460
|
-
if (!res.ok) {
|
|
461
|
-
const body = await res.text().catch(() => "");
|
|
462
|
-
return probeError(`GitHub Models ${res.status}: ${body.slice(0, 200)}`);
|
|
463
|
-
}
|
|
464
|
-
return ok({ auth: "pat" });
|
|
465
|
-
} catch (err) {
|
|
466
|
-
return transient(describeError(err));
|
|
467
|
-
}
|
|
439
|
+
const legacyPat = getIntegrationRaw("github-copilot")?.api_key?.trim();
|
|
440
|
+
if (!legacyPat) return unconfigured("Not signed in. Use device-flow sign-in from the Models panel.");
|
|
441
|
+
return authFailed("GitHub PAT authentication is no longer supported. Use device-flow sign-in from the Models panel.");
|
|
468
442
|
}
|
|
469
443
|
|
|
470
444
|
export async function probeClaudeCode(): Promise<HealthResult> {
|
|
@@ -24,7 +24,7 @@ export interface ModelEditorFormInput {
|
|
|
24
24
|
credential_id?: string | null;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export type ModelEditorPayload = Omit<ModelConfig, "name" | "created_at" | "updated_at">;
|
|
27
|
+
export type ModelEditorPayload = Omit<ModelConfig, "name" | "enabled" | "created_at" | "updated_at">;
|
|
28
28
|
|
|
29
29
|
export type BuildResult =
|
|
30
30
|
| { ok: true; name: string; payload: ModelEditorPayload }
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// GitHub Copilot device-flow login.
|
|
2
|
-
//
|
|
3
|
-
// Copilot session token and gets rate-limited on the Models REST API).
|
|
1
|
+
// GitHub Copilot device-flow login. The legacy PAT path depended on GitHub
|
|
2
|
+
// Models, which has been retired; use this flow for Copilot Chat access.
|
|
4
3
|
//
|
|
5
4
|
// Flow (RFC 8628):
|
|
6
5
|
// 1. POST github.com/login/device/code with the VS Code Copilot client_id.
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
2
2
|
import type AnthropicNS from "@anthropic-ai/sdk";
|
|
3
|
+
|
|
4
|
+
// Keep routing tests off the developer's real data directory: no stored OAuth token.
|
|
5
|
+
vi.mock("./github-copilot-auth", () => ({ getStoredOAuthToken: () => null }));
|
|
6
|
+
|
|
3
7
|
import { githubCopilotProvider, isCopilotClaudeModel } from "./github-copilot";
|
|
4
8
|
|
|
5
9
|
afterEach(() => vi.unstubAllGlobals());
|
|
@@ -113,4 +117,27 @@ describe("Claude-via-Copilot routing", () => {
|
|
|
113
117
|
}, [])).rejects.toMatchObject({ status: 429 });
|
|
114
118
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
115
119
|
});
|
|
120
|
+
it.each(["ghp_synthetic", "github_pat_synthetic"])("rejects PAT %s without network access", async (apiKey) => {
|
|
121
|
+
const fetch = vi.fn(async () => new Response("{}", { status: 503 }));
|
|
122
|
+
vi.stubGlobal("fetch", fetch);
|
|
123
|
+
|
|
124
|
+
await expect(githubCopilotProvider.invoke!("gpt-4o", [{ role: "user", content: "synthetic" }], {
|
|
125
|
+
api_key: apiKey,
|
|
126
|
+
}, [])).rejects.toThrow(/PAT route used GitHub Models.*retired/i);
|
|
127
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("routes explicit session tokens through the Copilot Chat endpoint", async () => {
|
|
131
|
+
const urls: string[] = [];
|
|
132
|
+
const fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
133
|
+
urls.push(input instanceof Request ? input.url : input.toString());
|
|
134
|
+
return new Response("{}", { status: 503 });
|
|
135
|
+
});
|
|
136
|
+
vi.stubGlobal("fetch", fetch);
|
|
137
|
+
|
|
138
|
+
await expect(githubCopilotProvider.invoke!("gpt-4o", [{ role: "user", content: "synthetic" }], {
|
|
139
|
+
copilot_session_token: "synthetic-session-token",
|
|
140
|
+
}, [])).rejects.toThrow();
|
|
141
|
+
expect(urls).toEqual(["https://api.githubcopilot.com/chat/completions"]);
|
|
142
|
+
});
|
|
116
143
|
});
|
|
@@ -55,19 +55,19 @@ interface SessionToken {
|
|
|
55
55
|
|
|
56
56
|
const tokenCache = new Map<string, SessionToken>();
|
|
57
57
|
|
|
58
|
-
function
|
|
59
|
-
return token.startsWith("ghp_") || token.startsWith("github_pat_")
|
|
58
|
+
function isGitHubPat(token: string): boolean {
|
|
59
|
+
return token.startsWith("ghp_") || token.startsWith("github_pat_");
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
async function
|
|
63
|
-
if (!
|
|
64
|
-
const cached = tokenCache.get(
|
|
62
|
+
async function exchangeCopilotSessionToken(githubToken: string): Promise<string> {
|
|
63
|
+
if (!githubToken) throw new Error("GitHub Copilot: no API key configured for token exchange.");
|
|
64
|
+
const cached = tokenCache.get(githubToken);
|
|
65
65
|
if (cached && cached.expiresAt - Date.now() > 60_000) return cached.token;
|
|
66
|
-
tokenCache.delete(
|
|
66
|
+
tokenCache.delete(githubToken);
|
|
67
67
|
|
|
68
68
|
const res = await fetch("https://api.github.com/copilot_internal/v2/token", {
|
|
69
69
|
headers: {
|
|
70
|
-
Authorization: `token ${
|
|
70
|
+
Authorization: `token ${githubToken}`,
|
|
71
71
|
"User-Agent": "Jarela/1.0",
|
|
72
72
|
},
|
|
73
73
|
signal: AbortSignal.timeout(30_000),
|
|
@@ -78,15 +78,14 @@ async function getCopilotToken(pat: string): Promise<string> {
|
|
|
78
78
|
if (res.status === 401) {
|
|
79
79
|
throw new Error(
|
|
80
80
|
`GitHub Copilot: token exchange failed (401). ` +
|
|
81
|
-
`
|
|
81
|
+
`Check that this GitHub account has an active Copilot subscription and the credential is valid. ` +
|
|
82
82
|
`Details: ${body}`,
|
|
83
83
|
);
|
|
84
84
|
}
|
|
85
85
|
if (res.status === 404) {
|
|
86
86
|
throw new Error(
|
|
87
|
-
"GitHub Copilot
|
|
88
|
-
"
|
|
89
|
-
"Use a Copilot session token in params.copilot_session_token, or a credential that supports token exchange.",
|
|
87
|
+
"GitHub Copilot could not exchange this credential for a session token. " +
|
|
88
|
+
"Use the in-app device-flow sign-in or set params.copilot_session_token.",
|
|
90
89
|
);
|
|
91
90
|
}
|
|
92
91
|
throw new Error(`GitHub Copilot: token exchange failed (${res.status}): ${body}`);
|
|
@@ -96,7 +95,7 @@ async function getCopilotToken(pat: string): Promise<string> {
|
|
|
96
95
|
if (!json.token) throw new Error("GitHub Copilot: token exchange returned no token");
|
|
97
96
|
|
|
98
97
|
const expiresAt = json.expires_at ? new Date(json.expires_at).getTime() : Date.now() + 28 * 60_000;
|
|
99
|
-
tokenCache.set(
|
|
98
|
+
tokenCache.set(githubToken, { token: json.token, expiresAt });
|
|
100
99
|
return json.token;
|
|
101
100
|
}
|
|
102
101
|
|
|
@@ -119,14 +118,8 @@ async function resolvedClient(params: ProviderParams): Promise<OpenAI> {
|
|
|
119
118
|
});
|
|
120
119
|
}
|
|
121
120
|
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
// Anthropic `cache_control` markers; the legacy PAT path falls back to
|
|
125
|
-
// GitHub Models REST (OpenAI-shaped, no Claude Messages support, capped at
|
|
126
|
-
// ~8K input tokens) and is incompatible with Anthropic routing.
|
|
127
|
-
type CopilotAuth =
|
|
128
|
-
| { kind: "copilot-api"; baseURL: string; apiKey: string; headers: Record<string, string> }
|
|
129
|
-
| { kind: "models-rest"; baseURL: string; apiKey: string; headers: Record<string, string> };
|
|
121
|
+
// Resolves credentials for Copilot's OpenAI-compatible and native Messages APIs.
|
|
122
|
+
type CopilotAuth = { baseURL: string; apiKey: string; headers: Record<string, string> };
|
|
130
123
|
|
|
131
124
|
async function resolveCopilotAuth(params: ProviderParams): Promise<CopilotAuth> {
|
|
132
125
|
const apiKey = (params.api_key as string | undefined) ?? "";
|
|
@@ -134,7 +127,6 @@ async function resolveCopilotAuth(params: ProviderParams): Promise<CopilotAuth>
|
|
|
134
127
|
|
|
135
128
|
if (explicitSessionToken && explicitSessionToken.length > 0) {
|
|
136
129
|
return {
|
|
137
|
-
kind: "copilot-api",
|
|
138
130
|
baseURL: params.base_url ?? "https://api.githubcopilot.com",
|
|
139
131
|
apiKey: explicitSessionToken,
|
|
140
132
|
headers: { ...FIXED_HEADERS },
|
|
@@ -143,25 +135,19 @@ async function resolveCopilotAuth(params: ProviderParams): Promise<CopilotAuth>
|
|
|
143
135
|
|
|
144
136
|
const oauthToken = getStoredOAuthToken();
|
|
145
137
|
if (oauthToken) {
|
|
146
|
-
const token = await
|
|
138
|
+
const token = await exchangeCopilotSessionToken(oauthToken);
|
|
147
139
|
return {
|
|
148
|
-
kind: "copilot-api",
|
|
149
140
|
baseURL: params.base_url ?? "https://api.githubcopilot.com",
|
|
150
141
|
apiKey: token,
|
|
151
142
|
headers: { ...FIXED_HEADERS },
|
|
152
143
|
};
|
|
153
144
|
}
|
|
154
145
|
|
|
155
|
-
if (apiKey &&
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
headers: {
|
|
161
|
-
Accept: "application/vnd.github+json",
|
|
162
|
-
"X-GitHub-Api-Version": "2026-03-10",
|
|
163
|
-
},
|
|
164
|
-
};
|
|
146
|
+
if (apiKey && isGitHubPat(apiKey)) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
"GitHub Copilot: this provider's GitHub PAT route used GitHub Models, which has been retired. " +
|
|
149
|
+
"Use the in-app device-flow sign-in or set copilot_session_token.",
|
|
150
|
+
);
|
|
165
151
|
}
|
|
166
152
|
|
|
167
153
|
if (!apiKey) {
|
|
@@ -169,9 +155,8 @@ async function resolveCopilotAuth(params: ProviderParams): Promise<CopilotAuth>
|
|
|
169
155
|
"GitHub Copilot: not signed in. Use the in-app device-flow login, or set api_key / copilot_session_token.",
|
|
170
156
|
);
|
|
171
157
|
}
|
|
172
|
-
const token = await
|
|
158
|
+
const token = await exchangeCopilotSessionToken(apiKey);
|
|
173
159
|
return {
|
|
174
|
-
kind: "copilot-api",
|
|
175
160
|
baseURL: params.base_url ?? "https://api.githubcopilot.com",
|
|
176
161
|
apiKey: token,
|
|
177
162
|
headers: { ...FIXED_HEADERS },
|
|
@@ -181,17 +166,9 @@ async function resolveCopilotAuth(params: ProviderParams): Promise<CopilotAuth>
|
|
|
181
166
|
// Anthropic SDK client pointed at Copilot's native `/v1/messages` endpoint.
|
|
182
167
|
// Copilot accepts the Anthropic request shape (including `cache_control`
|
|
183
168
|
// ephemeral breakpoints) for Claude-family models when called with a
|
|
184
|
-
// session token.
|
|
185
|
-
// this endpoint, so we surface a clear error there instead of silently
|
|
186
|
-
// downgrading to a path that strips the cache markers.
|
|
169
|
+
// Copilot session token.
|
|
187
170
|
async function resolvedAnthropicClient(params: ProviderParams): Promise<Anthropic> {
|
|
188
171
|
const auth = await resolveCopilotAuth(params);
|
|
189
|
-
if (auth.kind !== "copilot-api") {
|
|
190
|
-
throw new Error(
|
|
191
|
-
"GitHub Copilot: Claude models require a Copilot session token (OAuth or copilot_session_token). " +
|
|
192
|
-
"Personal Access Tokens route to GitHub Models REST, which does not expose the native Messages API.",
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
172
|
return new Anthropic({
|
|
196
173
|
// The SDK requires an apiKey field; we authenticate via authToken
|
|
197
174
|
// (Authorization: Bearer ...) per Copilot's contract. The empty
|
|
@@ -175,6 +175,13 @@ describe("integrations store (credentials-backed)", () => {
|
|
|
175
175
|
// And no credential was created as a side effect of the failed write.
|
|
176
176
|
expect(listCredentials({ type: "integration", provider: "anthropic" }).length).toBe(0);
|
|
177
177
|
});
|
|
178
|
+
|
|
179
|
+
it("does not create a configured row from an empty optional credential field", () => {
|
|
180
|
+
const result = saveIntegration("github-copilot", { api_key: "" });
|
|
181
|
+
expect("error" in result).toBe(true);
|
|
182
|
+
expect(getIntegrationStatus("github-copilot")?.configured).toBe(false);
|
|
183
|
+
expect(listCredentials({ type: "integration", provider: "github-copilot" })).toHaveLength(0);
|
|
184
|
+
});
|
|
178
185
|
});
|
|
179
186
|
|
|
180
187
|
describe("integrations store: multi-instance + ALS routing", () => {
|
|
@@ -176,11 +176,10 @@ export const INTEGRATIONS = {
|
|
|
176
176
|
label: "GitHub Copilot",
|
|
177
177
|
category: "llm" as IntegrationCategory,
|
|
178
178
|
description:
|
|
179
|
-
"Routes chat-completion requests through your Copilot subscription.
|
|
180
|
-
"
|
|
181
|
-
"the Models panel.",
|
|
179
|
+
"Routes chat-completion requests through your Copilot subscription. Use the device-flow " +
|
|
180
|
+
"sign-in in the Models panel; legacy GitHub PAT authentication is no longer supported.",
|
|
182
181
|
fields: [
|
|
183
|
-
{ key: "api_key", label: "GitHub PAT", placeholder: "
|
|
182
|
+
{ key: "api_key", label: "Legacy GitHub PAT (unsupported)", placeholder: "Use Models panel sign-in", secret: true, required: false },
|
|
184
183
|
],
|
|
185
184
|
},
|
|
186
185
|
gmail: {
|
|
@@ -437,6 +436,7 @@ export function saveIntegration(name: string, incoming: Record<string, string>):
|
|
|
437
436
|
else if (f.required) return { error: `missing required secret "${f.key}"` };
|
|
438
437
|
} else {
|
|
439
438
|
if (f.required && !v.trim()) return { error: `"${f.key}" cannot be empty` };
|
|
439
|
+
if (!v.trim()) continue;
|
|
440
440
|
merged[f.key] = v;
|
|
441
441
|
if (existing[f.key] !== v) touched.push(f.key);
|
|
442
442
|
}
|
|
@@ -118,3 +118,91 @@ describe("deleteModelConfig auto-promotes default", () => {
|
|
|
118
118
|
deleteModelConfig("keep-default");
|
|
119
119
|
});
|
|
120
120
|
});
|
|
121
|
+
|
|
122
|
+
describe("model config enable/disable and atomic default", () => {
|
|
123
|
+
async function load() {
|
|
124
|
+
return import("./model-config");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function reset(names: string[]) {
|
|
128
|
+
for (const n of names) deleteModelConfig(n);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
it("new rows are enabled and upsert without is_default preserves the default flag", async () => {
|
|
132
|
+
const { isModelConfigEnabled } = await load();
|
|
133
|
+
upsertModelConfig("ena-a", "anthropic", "claude-x", {}, true);
|
|
134
|
+
const saved = upsertModelConfig("ena-a", "anthropic", "claude-x", { temperature: 0.2 }, undefined);
|
|
135
|
+
expect(saved.is_default).toBe(1);
|
|
136
|
+
expect(isModelConfigEnabled(saved)).toBe(true);
|
|
137
|
+
reset(["ena-a"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("setDefaultModelConfig leaves exactly one default", async () => {
|
|
141
|
+
const { setDefaultModelConfig, listModelConfigs } = await load();
|
|
142
|
+
upsertModelConfig("def-a", "anthropic", "claude-x", {}, true);
|
|
143
|
+
upsertModelConfig("def-b", "anthropic", "claude-y", {}, false);
|
|
144
|
+
const row = setDefaultModelConfig("def-b");
|
|
145
|
+
expect(row.is_default).toBe(1);
|
|
146
|
+
expect(listModelConfigs().filter((r) => r.is_default === 1).map((r) => r.name)).toEqual(["def-b"]);
|
|
147
|
+
// Params are untouched by a default switch.
|
|
148
|
+
expect(getModelConfig("def-a")?.params).toBe(JSON.stringify({}));
|
|
149
|
+
reset(["def-a", "def-b"]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("setDefaultModelConfig rejects unknown and disabled configs without changing the default", async () => {
|
|
153
|
+
const { setDefaultModelConfig, setModelConfigEnabled, ModelConfigStateError } = await load();
|
|
154
|
+
upsertModelConfig("rej-a", "anthropic", "claude-x", {}, true);
|
|
155
|
+
upsertModelConfig("rej-b", "anthropic", "claude-y", {}, false);
|
|
156
|
+
setModelConfigEnabled("rej-b", false);
|
|
157
|
+
expect(() => setDefaultModelConfig("missing-model")).toThrow(ModelConfigStateError);
|
|
158
|
+
expect(() => setDefaultModelConfig("rej-b")).toThrow(/disabled/);
|
|
159
|
+
expect(getDefaultModelConfig()?.name).toBe("rej-a");
|
|
160
|
+
reset(["rej-a", "rej-b"]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("disabling the default promotes another enabled config in the same step", async () => {
|
|
164
|
+
const { setModelConfigEnabled, listModelConfigs } = await load();
|
|
165
|
+
upsertModelConfig("dis-a", "anthropic", "claude-x", {}, true);
|
|
166
|
+
upsertModelConfig("dis-b", "anthropic", "claude-y", {}, false);
|
|
167
|
+
const disabled = setModelConfigEnabled("dis-a", false);
|
|
168
|
+
expect(disabled.enabled).toBe(0);
|
|
169
|
+
expect(disabled.is_default).toBe(0);
|
|
170
|
+
const defaults = listModelConfigs().filter((r) => r.is_default === 1);
|
|
171
|
+
expect(defaults).toHaveLength(1);
|
|
172
|
+
expect(defaults[0].enabled).toBe(1);
|
|
173
|
+
expect(getDefaultModelConfig()?.name).not.toBe("dis-a");
|
|
174
|
+
reset(["dis-a", "dis-b"]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("refuses to disable the last enabled config", async () => {
|
|
178
|
+
const { setModelConfigEnabled, listModelConfigs } = await load();
|
|
179
|
+
for (const r of listModelConfigs()) deleteModelConfig(r.name);
|
|
180
|
+
upsertModelConfig("only-one", "anthropic", "claude-x", {}, true);
|
|
181
|
+
expect(() => setModelConfigEnabled("only-one", false)).toThrow(/at least one/i);
|
|
182
|
+
expect(getModelConfig("only-one")?.enabled).toBe(1);
|
|
183
|
+
reset(["only-one"]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("disabled configs are skipped by default fallback and runnable resolution", async () => {
|
|
187
|
+
const { setModelConfigEnabled, resolveRunnableModelConfig, listEnabledModelConfigs } = await load();
|
|
188
|
+
upsertModelConfig("run-a", "anthropic", "claude-x", {}, true);
|
|
189
|
+
upsertModelConfig("run-b", "anthropic", "claude-y", {}, false);
|
|
190
|
+
setModelConfigEnabled("run-b", false);
|
|
191
|
+
expect(listEnabledModelConfigs().map((r) => r.name)).not.toContain("run-b");
|
|
192
|
+
expect(resolveRunnableModelConfig("run-b")?.name).toBe("run-a");
|
|
193
|
+
setModelConfigEnabled("run-b", true);
|
|
194
|
+
expect(resolveRunnableModelConfig("run-b")?.name).toBe("run-b");
|
|
195
|
+
reset(["run-a", "run-b"]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("making a disabled config the default via upsert re-enables it", async () => {
|
|
199
|
+
const { setModelConfigEnabled } = await load();
|
|
200
|
+
upsertModelConfig("up-a", "anthropic", "claude-x", {}, true);
|
|
201
|
+
upsertModelConfig("up-b", "anthropic", "claude-y", {}, false);
|
|
202
|
+
setModelConfigEnabled("up-b", false);
|
|
203
|
+
const row = upsertModelConfig("up-b", "anthropic", "claude-y", {}, true);
|
|
204
|
+
expect(row.enabled).toBe(1);
|
|
205
|
+
expect(getDefaultModelConfig()?.name).toBe("up-b");
|
|
206
|
+
reset(["up-a", "up-b"]);
|
|
207
|
+
});
|
|
208
|
+
});
|