tau-coding-agent 0.1.5 → 0.1.6

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.
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Compatibility workaround for sandbox-runtime on macOS.
5
+ *
6
+ * The runtime routes Git-over-SSH through BSD nc, which cannot authenticate to
7
+ * its SOCKS5 proxy. This shell preserves normal bash execution while replacing
8
+ * that transport with an authenticated HTTP CONNECT tunnel.
9
+ *
10
+ * Remove once the upstream fix ships:
11
+ * https://github.com/anthropic-experimental/sandbox-runtime/pull/385
12
+ */
13
+
14
+ import { spawn } from "node:child_process";
15
+ import { connect } from "node:net";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const PROXY_CONNECT_MODE = "--proxy-connect";
19
+ const CONNECT_TIMEOUT_MS = 15_000;
20
+ const MAX_RESPONSE_HEADER_BYTES = 64 * 1024;
21
+
22
+ if (process.argv[2] === PROXY_CONNECT_MODE) {
23
+ connectThroughSandboxProxy(process.argv[3], process.argv[4]);
24
+ } else {
25
+ runBash(process.argv.slice(2));
26
+ }
27
+
28
+ function runBash(args) {
29
+ if (hasAuthenticatedSandboxProxy()) {
30
+ const scriptPath = fileURLToPath(import.meta.url);
31
+ const proxyCommand = [
32
+ shellQuote(escapeSshPercent(process.execPath)),
33
+ shellQuote(escapeSshPercent(scriptPath)),
34
+ PROXY_CONNECT_MODE,
35
+ "%h",
36
+ "%p",
37
+ ].join(" ");
38
+ process.env.GIT_SSH_COMMAND = [
39
+ "ssh",
40
+ "-o ControlMaster=no",
41
+ "-o ControlPath=none",
42
+ `-o ProxyCommand=${shellQuote(proxyCommand)}`,
43
+ ].join(" ");
44
+ }
45
+
46
+ const child = spawn("bash", args, {
47
+ env: process.env,
48
+ stdio: "inherit",
49
+ });
50
+ child.once("error", (error) => {
51
+ console.error(error.message);
52
+ process.exitCode = 1;
53
+ });
54
+ child.once("exit", (code, signal) => {
55
+ if (signal) {
56
+ process.kill(process.pid, signal);
57
+ return;
58
+ }
59
+ process.exitCode = code ?? 1;
60
+ });
61
+ }
62
+
63
+ function hasAuthenticatedSandboxProxy() {
64
+ try {
65
+ const proxy = getSandboxProxyUrl();
66
+ return proxy.protocol === "http:" && Boolean(proxy.username || proxy.password);
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ function connectThroughSandboxProxy(targetHost, targetPortText) {
73
+ const targetPort = Number(targetPortText);
74
+ if (
75
+ !targetHost ||
76
+ /[\r\n]/u.test(targetHost) ||
77
+ !Number.isInteger(targetPort) ||
78
+ targetPort < 1 ||
79
+ targetPort > 65_535
80
+ ) {
81
+ fail("Invalid SSH proxy target.");
82
+ }
83
+
84
+ let proxy;
85
+ try {
86
+ proxy = getSandboxProxyUrl();
87
+ } catch (error) {
88
+ fail(error);
89
+ }
90
+ if (proxy.protocol !== "http:") {
91
+ fail(`Unsupported sandbox proxy protocol: ${proxy.protocol}`);
92
+ }
93
+
94
+ const proxyPort = Number(proxy.port || 80);
95
+ const authority = targetHost.includes(":")
96
+ ? `[${targetHost}]:${targetPort}`
97
+ : `${targetHost}:${targetPort}`;
98
+ let username;
99
+ let password;
100
+ try {
101
+ username = decodeURIComponent(proxy.username);
102
+ password = decodeURIComponent(proxy.password);
103
+ } catch {
104
+ fail("Sandbox HTTP proxy credentials are invalid.");
105
+ }
106
+ const authorization =
107
+ username || password
108
+ ? `Proxy-Authorization: Basic ${Buffer.from(`${username}:${password}`).toString("base64")}\r\n`
109
+ : "";
110
+
111
+ const socket = connect({ host: proxy.hostname, port: proxyPort });
112
+ const connectTimeout = setTimeout(() => {
113
+ socket.destroy(new Error("Sandbox proxy connection timed out."));
114
+ }, CONNECT_TIMEOUT_MS);
115
+ let response = Buffer.alloc(0);
116
+ let tunnelEstablished = false;
117
+ let connectionFailed = false;
118
+
119
+ socket.once("connect", () => {
120
+ clearTimeout(connectTimeout);
121
+ socket.write(
122
+ `CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n${authorization}Proxy-Connection: Keep-Alive\r\n\r\n`,
123
+ );
124
+ });
125
+
126
+ const failConnection = (error) => {
127
+ if (connectionFailed) return;
128
+ connectionFailed = true;
129
+ clearTimeout(connectTimeout);
130
+ console.error(error instanceof Error ? error.message : String(error));
131
+ process.exitCode = 1;
132
+ socket.destroy();
133
+ };
134
+
135
+ const handleResponse = (chunk) => {
136
+ response = Buffer.concat([response, chunk]);
137
+ if (response.length > MAX_RESPONSE_HEADER_BYTES) {
138
+ failConnection(new Error("Sandbox proxy returned oversized headers."));
139
+ return;
140
+ }
141
+
142
+ const headerEnd = response.indexOf("\r\n\r\n");
143
+ if (headerEnd === -1) return;
144
+
145
+ const header = response.subarray(0, headerEnd).toString("latin1");
146
+ const status = header.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/u)?.[1];
147
+ if (status !== "200") {
148
+ failConnection(new Error(`Sandbox proxy CONNECT failed${status ? ` (${status})` : ""}.`));
149
+ return;
150
+ }
151
+
152
+ tunnelEstablished = true;
153
+ socket.off("data", handleResponse);
154
+ const remainder = response.subarray(headerEnd + 4);
155
+ if (remainder.length > 0) process.stdout.write(remainder);
156
+ process.stdin.pipe(socket);
157
+ socket.pipe(process.stdout);
158
+ };
159
+
160
+ socket.on("data", handleResponse);
161
+ socket.once("error", failConnection);
162
+ socket.once("close", () => {
163
+ clearTimeout(connectTimeout);
164
+ process.stdin.unpipe(socket);
165
+ process.stdin.pause();
166
+ if (!tunnelEstablished && !connectionFailed) {
167
+ failConnection(new Error("Sandbox proxy closed before establishing the SSH tunnel."));
168
+ }
169
+ });
170
+ process.stdin.on("error", failConnection);
171
+ process.stdout.on("error", (error) => {
172
+ if (error?.code === "EPIPE") {
173
+ socket.destroy();
174
+ return;
175
+ }
176
+ failConnection(error);
177
+ });
178
+ }
179
+
180
+ function getSandboxProxyUrl() {
181
+ const value = process.env.HTTPS_PROXY ?? process.env.HTTP_PROXY;
182
+ if (!value) throw new Error("Sandbox HTTP proxy is unavailable.");
183
+
184
+ try {
185
+ return new URL(value);
186
+ } catch {
187
+ throw new Error("Sandbox HTTP proxy URL is invalid.");
188
+ }
189
+ }
190
+
191
+ function escapeSshPercent(value) {
192
+ return value.replaceAll("%", "%%");
193
+ }
194
+
195
+ function shellQuote(value) {
196
+ return `'${value.replaceAll("'", `'\\''`)}'`;
197
+ }
198
+
199
+ function fail(error) {
200
+ console.error(error instanceof Error ? error.message : String(error));
201
+ process.exit(1);
202
+ }
@@ -1,4 +1,4 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { readStoredCredential, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  clampPercent,
4
4
  formatCurrency,
@@ -111,7 +111,7 @@ async function fetchUsage(ctx: ExtensionContext, signal?: AbortSignal): Promise<
111
111
  const token = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER_ID);
112
112
  if (!token) throw new LiveUsageUnavailableError("no auth configured");
113
113
 
114
- const credential = ctx.modelRegistry.authStorage.get(PROVIDER_ID);
114
+ const credential = readStoredCredential(PROVIDER_ID);
115
115
  if (credential?.type === "api_key") {
116
116
  throw new LiveUsageUnavailableError("API-key auth does not expose Claude subscription quota");
117
117
  }
@@ -170,12 +170,11 @@ async function fetchUsage(ctx: ExtensionContext, signal?: AbortSignal): Promise<
170
170
  };
171
171
  }
172
172
 
173
- async function getLiveUsageAvailability(ctx: ExtensionContext) {
174
- if (!ctx.modelRegistry.authStorage.hasAuth(PROVIDER_ID)) {
173
+ async function getLiveUsageAvailability(_ctx: ExtensionContext) {
174
+ const credential = readStoredCredential(PROVIDER_ID);
175
+ if (!credential) {
175
176
  return { available: false as const, reason: "no auth configured" };
176
177
  }
177
-
178
- const credential = ctx.modelRegistry.authStorage.get(PROVIDER_ID);
179
178
  if (credential?.type === "oauth") {
180
179
  return { available: true as const };
181
180
  }
@@ -1,4 +1,5 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { OAuthCredential } from "@earendil-works/pi-ai";
2
+ import { readStoredCredential, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import {
3
4
  clampPercent,
4
5
  formatCount,
@@ -8,7 +9,12 @@ import {
8
9
  readNumber,
9
10
  readString,
10
11
  } from "./shared.js";
11
- import type { LiveUsageItem, LiveUsageSnapshot, UsageProviderDefinition } from "./types.js";
12
+ import type {
13
+ LiveUsageAvailability,
14
+ LiveUsageItem,
15
+ LiveUsageSnapshot,
16
+ UsageProviderDefinition,
17
+ } from "./types.js";
12
18
 
13
19
  const PROVIDER_ID = "github-copilot";
14
20
 
@@ -136,13 +142,18 @@ function buildUsageUrl(enterpriseUrl?: string): string {
136
142
  return `https://${host}/copilot_internal/user`;
137
143
  }
138
144
 
139
- async function fetchUsage(ctx: ExtensionContext, signal?: AbortSignal): Promise<LiveUsageSnapshot> {
140
- const credential = ctx.modelRegistry.authStorage.get(PROVIDER_ID);
141
- if (
142
- credential?.type !== "oauth" ||
143
- typeof credential.refresh !== "string" ||
144
- !credential.refresh.trim()
145
- ) {
145
+ async function getLiveUsageAvailability(_ctx: ExtensionContext): Promise<LiveUsageAvailability> {
146
+ return getStoredCopilotCredential()
147
+ ? { available: true }
148
+ : { available: false, reason: "not logged in" };
149
+ }
150
+
151
+ async function fetchUsage(
152
+ _ctx: ExtensionContext,
153
+ signal?: AbortSignal,
154
+ ): Promise<LiveUsageSnapshot> {
155
+ const credential = getStoredCopilotCredential();
156
+ if (!credential) {
146
157
  throw new Error("Not logged in to GitHub Copilot.");
147
158
  }
148
159
 
@@ -193,11 +204,21 @@ async function fetchUsage(ctx: ExtensionContext, signal?: AbortSignal): Promise<
193
204
  };
194
205
  }
195
206
 
207
+ function getStoredCopilotCredential(): OAuthCredential | undefined {
208
+ const credential = readStoredCredential(PROVIDER_ID);
209
+ return credential?.type === "oauth" &&
210
+ typeof credential.refresh === "string" &&
211
+ credential.refresh.trim()
212
+ ? credential
213
+ : undefined;
214
+ }
215
+
196
216
  const githubCopilotProvider: UsageProviderDefinition = {
197
217
  id: PROVIDER_ID,
198
218
  label: "GitHub Copilot",
199
219
  shortLabel: "copilot",
200
220
  color: { r: 168, g: 85, b: 247 },
221
+ getLiveUsageAvailability,
201
222
  fetchLiveUsage: fetchUsage,
202
223
  };
203
224
 
@@ -1433,7 +1433,7 @@ async function getLiveUsageAvailability(
1433
1433
  return provider.getLiveUsageAvailability(ctx);
1434
1434
  }
1435
1435
 
1436
- return ctx.modelRegistry.authStorage.hasAuth(provider.id)
1436
+ return ctx.modelRegistry.getProviderAuthStatus(provider.id).configured
1437
1437
  ? { available: true }
1438
1438
  : { available: false, reason: "no auth configured" };
1439
1439
  }
@@ -1,4 +1,4 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { readStoredCredential, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  clampPercent,
4
4
  formatCurrency,
@@ -112,7 +112,7 @@ async function fetchUsage(ctx: ExtensionContext, signal?: AbortSignal): Promise<
112
112
  const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER_ID);
113
113
  if (!apiKey) throw new Error("Not logged in to OpenAI Codex.");
114
114
 
115
- const credential = ctx.modelRegistry.authStorage.get(PROVIDER_ID);
115
+ const credential = readStoredCredential(PROVIDER_ID);
116
116
  const accountId =
117
117
  credential?.type === "oauth" && typeof credential.accountId === "string"
118
118
  ? credential.accountId
@@ -5,6 +5,7 @@ export interface PiModelSelection {
5
5
  model: Model<Api>;
6
6
  apiKey?: string;
7
7
  headers?: Record<string, string>;
8
+ env?: Record<string, string>;
8
9
  }
9
10
 
10
11
  export async function selectCurrentPiModel(
@@ -42,7 +43,24 @@ async function resolvePiModelSelection(
42
43
  ctx: Pick<ExtensionContext, "modelRegistry">,
43
44
  ): Promise<PiModelSelection | null> {
44
45
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
45
- return auth.ok ? { model, apiKey: auth.apiKey, headers: auth.headers } : null;
46
+ return auth.ok
47
+ ? {
48
+ model: resolveModelEnv(model, auth.env),
49
+ apiKey: auth.apiKey,
50
+ headers: auth.headers,
51
+ env: auth.env,
52
+ }
53
+ : null;
54
+ }
55
+
56
+ function resolveModelEnv(model: Model<Api>, env?: Record<string, string>): Model<Api> {
57
+ if (!model.baseUrl || !env) return model;
58
+
59
+ let baseUrl = model.baseUrl;
60
+ for (const [name, value] of Object.entries(env)) {
61
+ baseUrl = baseUrl.replaceAll(`{${name}}`, value);
62
+ }
63
+ return baseUrl === model.baseUrl ? model : { ...model, baseUrl };
46
64
  }
47
65
 
48
66
  function rankPiModels(models: Model<Api>[]): Model<Api>[] {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tau-coding-agent",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Opinionated Pi package with web search, review workflows, sandboxing, memory, usage reporting, skills, and a theme",
5
5
  "keywords": [
6
6
  "agent-tools",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "type": "module",
29
29
  "dependencies": {
30
- "@anthropic-ai/sandbox-runtime": "^0.0.49",
30
+ "@anthropic-ai/sandbox-runtime": "^0.0.67",
31
31
  "@mozilla/readability": "^0.6.0",
32
32
  "jsdom": "^29.0.2",
33
33
  "puppeteer-core": "^24.40.0",
@@ -43,14 +43,14 @@ Run commands from this skill directory:
43
43
  ./scripts/oracle-bundle -p "<task>" --file "src/**" --file "!**/*.test.*"
44
44
 
45
45
  # Show which oracle model would be selected
46
- ./scripts/oracle --list-models --current openai-codex/gpt-5.5
46
+ ./scripts/oracle --list-models --current openai-codex/gpt-5.6-sol
47
47
 
48
48
  # Ask the automatically selected oracle model
49
- ./scripts/oracle --current openai-codex/gpt-5.5 \
49
+ ./scripts/oracle --current openai-codex/gpt-5.6-sol \
50
50
  -p "<task>" --file "src/**" --file "!**/*.test.*"
51
51
 
52
52
  # Override the oracle model when the automatic choice is wrong
53
- ./scripts/oracle --model github-copilot/claude-opus-4.7 \
53
+ ./scripts/oracle --model anthropic/claude-fable-5 \
54
54
  -p "<task>" --file "src/**" --file "!**/*.test.*"
55
55
  ```
56
56
 
@@ -144,82 +144,86 @@ function selectOracleModel(models, enabledModels, currentFamily) {
144
144
 
145
145
  function bestModelForFamily(models, enabledModels, family) {
146
146
  return models
147
- .map((model) => ({ model, score: scoreModel(model, enabledModels, family) }))
148
- .filter((entry) => entry.score >= 0)
149
- .sort((a, b) => b.score - a.score || a.model.spec.localeCompare(b.model.spec))[0]?.model;
147
+ .filter((model) => modelFamily(model.spec) === family)
148
+ .sort((left, right) => compareModels(left, right, enabledModels, family))[0];
150
149
  }
151
150
 
152
- function scoreModel(entry, enabledModels, targetFamily) {
153
- if (modelFamily(entry.spec) !== targetFamily) return -1;
154
-
155
- let score = enabledModels.has(normalizeModelSpec(entry.spec)) ? 10_000 : 0;
156
- score += providerScore(entry.provider, targetFamily);
157
- score += nameScore(entry.model, targetFamily);
158
- return score;
151
+ function compareModels(left, right, enabledModels, family) {
152
+ return (
153
+ Number(enabledModels.has(normalizeModelSpec(right.spec))) -
154
+ Number(enabledModels.has(normalizeModelSpec(left.spec))) ||
155
+ modelRank(right.model, family) - modelRank(left.model, family) ||
156
+ providerRank(right.provider, family) - providerRank(left.provider, family) ||
157
+ left.spec.localeCompare(right.spec)
158
+ );
159
159
  }
160
160
 
161
- function providerScore(provider, family) {
161
+ function providerRank(provider, family) {
162
162
  const normalized = provider.toLowerCase();
163
163
  if (family === "openai") {
164
- if (normalized === "openai-codex") return 700;
165
- if (normalized === "openai") return 600;
166
- if (normalized === "github-copilot") return 500;
167
- if (normalized.includes("azure")) return 450;
168
- if (normalized === "openrouter") return 300;
169
- return 100;
164
+ if (normalized === "openai-codex") return 5;
165
+ if (normalized === "openai") return 4;
166
+ if (normalized === "github-copilot") return 3;
167
+ if (normalized.includes("azure")) return 2;
168
+ if (normalized === "openrouter") return 1;
169
+ return 0;
170
170
  }
171
171
 
172
172
  if (family === "anthropic") {
173
- if (normalized === "github-copilot") return 700;
174
- if (normalized === "anthropic") return 600;
175
- if (normalized === "amazon-bedrock") return 450;
176
- if (normalized === "openrouter") return 300;
177
- return 100;
173
+ if (normalized === "github-copilot") return 4;
174
+ if (normalized === "anthropic") return 3;
175
+ if (normalized === "amazon-bedrock") return 2;
176
+ if (normalized === "openrouter") return 1;
177
+ return 0;
178
178
  }
179
179
 
180
180
  if (family === "google") {
181
- if (normalized === "google") return 600;
182
- if (normalized === "github-copilot") return 500;
183
- if (normalized.includes("vertex")) return 450;
184
- if (normalized === "openrouter") return 300;
185
- return 100;
181
+ if (normalized === "google") return 4;
182
+ if (normalized === "github-copilot") return 3;
183
+ if (normalized.includes("vertex")) return 2;
184
+ if (normalized === "openrouter") return 1;
185
+ return 0;
186
186
  }
187
187
 
188
188
  return 0;
189
189
  }
190
190
 
191
- function nameScore(model, family) {
191
+ function modelRank(model, family) {
192
192
  const normalized = model.toLowerCase();
193
193
 
194
194
  if (family === "openai") {
195
- if (/gpt-5\.5/.test(normalized)) return 1_000;
196
- if (/gpt-5\.4/.test(normalized)) return 950;
197
- if (/gpt-5\.3.*codex/.test(normalized)) return 925;
198
- if (/gpt-5\.3/.test(normalized)) return 900;
199
- if (/gpt-5\.2/.test(normalized)) return 850;
200
- if (/gpt-5\.1/.test(normalized)) return 800;
201
- if (/gpt-5/.test(normalized)) return 750;
202
- if (/gpt-4\.1/.test(normalized)) return 500;
203
- return 100;
195
+ if (/gpt-5\.6[-_.]sol/.test(normalized)) return 10;
196
+ if (/gpt-5\.6/.test(normalized)) return 9;
197
+ if (/gpt-5\.5/.test(normalized)) return 8;
198
+ if (/gpt-5\.4/.test(normalized)) return 7;
199
+ if (/gpt-5\.3.*codex/.test(normalized)) return 6;
200
+ if (/gpt-5\.3/.test(normalized)) return 5;
201
+ if (/gpt-5\.2/.test(normalized)) return 4;
202
+ if (/gpt-5\.1/.test(normalized)) return 3;
203
+ if (/gpt-5/.test(normalized)) return 2;
204
+ if (/gpt-4\.1/.test(normalized)) return 1;
205
+ return 0;
204
206
  }
205
207
 
206
208
  if (family === "anthropic") {
207
- if (/claude[-_.]opus[-_.]4[-_.]7/.test(normalized)) return 1_000;
208
- if (/claude[-_.]opus[-_.]4[-_.]6/.test(normalized)) return 950;
209
- if (/claude[-_.]opus[-_.]4[-_.]5/.test(normalized)) return 900;
210
- if (/claude[-_.]opus[-_.]4[-_.]1/.test(normalized)) return 850;
211
- if (/claude[-_.]opus[-_.]4/.test(normalized)) return 800;
212
- if (/claude[-_.]sonnet[-_.]4[-_.]6/.test(normalized)) return 700;
213
- if (/claude[-_.]sonnet[-_.]4[-_.]5/.test(normalized)) return 650;
214
- if (/claude[-_.]sonnet[-_.]4/.test(normalized)) return 600;
215
- return 100;
209
+ if (/claude[-_.]fable[-_.]5/.test(normalized)) return 10;
210
+ if (/claude[-_.]opus[-_.]4[-_.]8/.test(normalized)) return 9;
211
+ if (/claude[-_.]opus[-_.]4[-_.]7/.test(normalized)) return 8;
212
+ if (/claude[-_.]opus[-_.]4[-_.]6/.test(normalized)) return 7;
213
+ if (/claude[-_.]opus[-_.]4[-_.]5/.test(normalized)) return 6;
214
+ if (/claude[-_.]opus[-_.]4[-_.]1/.test(normalized)) return 5;
215
+ if (/claude[-_.]opus[-_.]4/.test(normalized)) return 4;
216
+ if (/claude[-_.]sonnet[-_.]4[-_.]6/.test(normalized)) return 3;
217
+ if (/claude[-_.]sonnet[-_.]4[-_.]5/.test(normalized)) return 2;
218
+ if (/claude[-_.]sonnet[-_.]4/.test(normalized)) return 1;
219
+ return 0;
216
220
  }
217
221
 
218
222
  if (family === "google") {
219
- if (/gemini[-_.]3\.1[-_.]pro/.test(normalized)) return 1_000;
220
- if (/gemini[-_.]3[-_.]pro/.test(normalized)) return 950;
221
- if (/gemini[-_.]2\.5[-_.]pro/.test(normalized)) return 800;
222
- return 100;
223
+ if (/gemini[-_.]3\.1[-_.]pro/.test(normalized)) return 3;
224
+ if (/gemini[-_.]3[-_.]pro/.test(normalized)) return 2;
225
+ if (/gemini[-_.]2\.5[-_.]pro/.test(normalized)) return 1;
226
+ return 0;
223
227
  }
224
228
 
225
229
  return 0;