@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.
Files changed (86) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +2 -2
  3. package/.next/standalone/.next/build-manifest.json +2 -2
  4. package/.next/standalone/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  13. package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/api/v1/models/[name]/route.js +61 -20
  19. package/.next/standalone/.next/server/app/api/v1/models/route.js +5 -3
  20. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +27 -24
  21. package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/page.js +313 -251
  23. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  24. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  25. package/.next/standalone/.next/server/app-paths-manifest.json +2 -2
  26. package/.next/standalone/.next/server/chunks/2471.js +17 -17
  27. package/.next/standalone/.next/server/chunks/2563.js +24 -50
  28. package/.next/standalone/.next/server/chunks/3399.js +10695 -10695
  29. package/.next/standalone/.next/server/chunks/3524.js +5 -33
  30. package/.next/standalone/.next/server/chunks/424.js +10 -5
  31. package/.next/standalone/.next/server/chunks/4508.js +2 -2
  32. package/.next/standalone/.next/server/chunks/46.js +111 -46
  33. package/.next/standalone/.next/server/chunks/466.js +88 -13
  34. package/.next/standalone/.next/server/chunks/6649.js +1 -1
  35. package/.next/standalone/.next/server/chunks/9590.js +108 -46
  36. package/.next/standalone/.next/server/chunks/9640.js +3 -6
  37. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  38. package/.next/standalone/.next/server/middleware.js +104 -45
  39. package/.next/standalone/.next/server/pages/404.html +1 -1
  40. package/.next/standalone/.next/server/pages/500.html +1 -1
  41. package/.next/standalone/.next/server/preview-props.json +3 -3
  42. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  43. package/.next/standalone/.next/static/chunks/{1395-f201e3e2e1f57715.js → 1395-a340fb0a345382a4.js} +89 -14
  44. package/.next/standalone/.next/static/chunks/{4576-13f7546a145a3624.js → 7045-8bc2f14e430ca661.js} +58 -2
  45. package/.next/standalone/.next/static/chunks/app/{page-c6e972a7fd13dae8.js → page-7017f07f8a7a083c.js} +170 -151
  46. package/.next/standalone/package.json +1 -1
  47. package/CHANGELOG.md +8 -0
  48. package/api/client.models.test.ts +31 -0
  49. package/api/client.ts +16 -1
  50. package/api/types.ts +8 -0
  51. package/app/api/v1/models/[name]/route.test.ts +89 -0
  52. package/app/api/v1/models/[name]/route.ts +54 -3
  53. package/app/api/v1/models/route.ts +4 -2
  54. package/app/api/v1/providers/[provider]/models/route.test.ts +53 -0
  55. package/app/api/v1/providers/[provider]/models/route.ts +27 -16
  56. package/components/agents/agent-editor/AdvancedSection.tsx +8 -2
  57. package/components/agents/agent-editor/ModelSection.tsx +6 -3
  58. package/components/models/ModelEditor.tsx +1 -1
  59. package/components/models/ModelsPanel.tsx +34 -8
  60. package/components/models/model-editor/GitHubCopilotAuth.tsx +1 -1
  61. package/components/models/model-editor/save-helpers.ts +2 -2
  62. package/components/models/model-editor/useModelEditorForm.ts +1 -1
  63. package/components/models/model-editor/useModelSaveHandlers.ts +1 -1
  64. package/components/setup/wizard/StepCredentials.tsx +1 -1
  65. package/components/setup/wizard/StepModel.tsx +3 -8
  66. package/hooks/useModels.ts +17 -1
  67. package/lib/agents/effective-model.test.ts +45 -0
  68. package/lib/agents/effective-model.ts +11 -5
  69. package/lib/agents/llm.ts +2 -2
  70. package/lib/agents/run-thread.ts +10 -5
  71. package/lib/agents/warm-summary-background.test.ts +1 -2
  72. package/lib/agents/warm-summary-background.ts +4 -7
  73. package/lib/db/migrations.ts +10 -0
  74. package/lib/health/probes.test.ts +8 -11
  75. package/lib/health/probes.ts +5 -31
  76. package/lib/models/editor-payload.ts +1 -1
  77. package/lib/providers/github-copilot-auth.ts +2 -3
  78. package/lib/providers/github-copilot.test.ts +27 -0
  79. package/lib/providers/github-copilot.ts +21 -44
  80. package/lib/stores/integrations.test.ts +7 -0
  81. package/lib/stores/integrations.ts +4 -4
  82. package/lib/stores/model-config.test.ts +88 -0
  83. package/lib/stores/model-config.ts +90 -8
  84. package/package.json +1 -1
  85. /package/.next/standalone/.next/static/{e2NV1wk6mWg3SIeELXevg → NUrC9xax1sp1Q70nlFqS6}/_buildManifest.js +0 -0
  86. /package/.next/standalone/.next/static/{e2NV1wk6mWg3SIeELXevg → NUrC9xax1sp1Q70nlFqS6}/_ssgManifest.js +0 -0
@@ -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, listModelConfigs } from "@/lib/stores/model-config";
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) return 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 agentCfg.model_config_name ?? getDefaultModelConfig()?.name ?? null;
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
- if (modelConfigName && !getModelConfig(modelConfigName)) {
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: listModelConfigs().map((cfg) => ({
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
- getDefaultModelConfig: () => ({ name: "model-1" }),
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 { getDefaultModelConfig, getModelConfig, getModelParams } from "@/lib/stores/model-config";
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 modelName = agent.model_config_name ?? getDefaultModelConfig()?.name ?? null;
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 modelName = agent.model_config_name ?? getDefaultModelConfig()?.name ?? null;
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 modelName = agent.model_config_name ?? getDefaultModelConfig()?.name ?? null;
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);
@@ -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 fallback: ok against the Models API", async () => {
566
- saveIntegration("github-copilot", { api_key: "ghp_xxx" });
567
- mockFetch(() => jsonResponse({ models: [] }));
568
- const r = await probeGithubCopilot();
569
- expect(r.status).toBe("ok");
570
- expect(r.detail?.auth).toBe("pat");
571
- });
572
- it("PAT fallback: 401 auth_failed", async () => {
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");
@@ -410,13 +410,8 @@ export async function probeCohere(): Promise<HealthResult> {
410
410
  }
411
411
 
412
412
  export async function probeGithubCopilot(): Promise<HealthResult> {
413
- // Two credential paths share this probe:
414
- // 1. Device-flow OAuth token (stored under memory_store namespace
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 pat = getIntegrationRaw("github-copilot")?.api_key?.trim();
445
- if (!pat) return unconfigured("Not signed in and no PAT configured");
446
- try {
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. Lets users sign in with their Copilot
2
- // subscription instead of pasting a PAT (which can't be exchanged for a
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 isLikelyGitHubPat(token: string): boolean {
59
- return token.startsWith("ghp_") || token.startsWith("github_pat_") || token.startsWith("gho_");
58
+ function isGitHubPat(token: string): boolean {
59
+ return token.startsWith("ghp_") || token.startsWith("github_pat_");
60
60
  }
61
61
 
62
- async function getCopilotToken(pat: string): Promise<string> {
63
- if (!pat) throw new Error("GitHub Copilot: no API key configured for token exchange.");
64
- const cached = tokenCache.get(pat);
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(pat);
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 ${pat}`,
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
- `Ensure your PAT has the "copilot" scope and that you have an active Copilot subscription. ` +
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 token exchange endpoint was not found for this credential. " +
88
- "Personal Access Tokens are not accepted directly by Copilot chat. " +
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(pat, { token: json.token, expiresAt });
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
- // Returns the auth + transport details needed to talk to GitHub Copilot. The
123
- // session-token + native-Messages-API path is preferred because it preserves
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 getCopilotToken(oauthToken);
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 && isLikelyGitHubPat(apiKey)) {
156
- return {
157
- kind: "models-rest",
158
- baseURL: params.base_url ?? "https://models.github.ai/inference",
159
- apiKey,
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 getCopilotToken(apiKey);
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. The PAT-only `models.github.ai` fallback does NOT expose
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. Paste a GitHub PAT " +
180
- "with `copilot` scope from github.com/settings/tokens, or run the device-flow sign-in in " +
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: "ghp_… or github_pat_…", secret: true, required: true },
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
+ });