diff --git a/src/lib/auto-session.ts b/src/lib/auto-session.ts new file mode 100644 index 0000000..2fb41bc --- /dev/null +++ b/src/lib/auto-session.ts @@ -0,0 +1,99 @@ +import consola from "consola" + +import type { BridgeConfig } from "~/lib/config" +import { runtimeState } from "~/lib/state" + +interface AutoSessionResponse { + session_token: string + available_models?: Array + expires_at?: number | string +} + +const AUTO_MODE_BODY = { auto_mode: { model_hints: ["auto"] } } +const FALLBACK_REFRESH_SECONDS = 30 * 60 + +const parseExpiresAt = (value: number | string | undefined): number | undefined => { + if (typeof value === "number" && Number.isFinite(value)) { + return value + } + if (typeof value === "string") { + const numeric = Number(value) + if (Number.isFinite(numeric)) { + return numeric + } + const parsed = Date.parse(value) + if (Number.isFinite(parsed)) { + return Math.floor(parsed / 1000) + } + } + return undefined +} + +export const fetchAutoSession = async ( + config: BridgeConfig, +): Promise => { + if (!config.copilotToken) { + throw new Error( + "COPILOT_TOKEN is not configured; cannot start auto mode", + ) + } + + const response = await fetch(`${config.copilotBaseUrl}/models/session`, { + method: "POST", + headers: { + authorization: `Bearer ${config.copilotToken}`, + "content-type": "application/json", + "x-github-api-version": "2025-10-01", + "copilot-integration-id": "vscode-chat", + }, + body: JSON.stringify(AUTO_MODE_BODY), + }) + + if (!response.ok) { + const text = await response.text().catch(() => "") + throw new Error( + `Failed to acquire auto-mode session token: ${response.status} ${response.statusText}\n${text}`, + ) + } + + return (await response.json()) as AutoSessionResponse +} + +const applyAutoSession = async (config: BridgeConfig) => { + const data = await fetchAutoSession(config) + runtimeState.autoSessionToken = data.session_token + runtimeState.autoExpiresAt = parseExpiresAt(data.expires_at) + runtimeState.autoAvailableModels = data.available_models + return data +} + +const scheduleAutoSessionRefresh = (config: BridgeConfig) => { + const now = Math.floor(Date.now() / 1000) + const expiresAt = + runtimeState.autoExpiresAt ?? now + FALLBACK_REFRESH_SECONDS + const refreshIn = Math.max(expiresAt - now - 60, 60) + + const timer = setTimeout(async () => { + try { + await applyAutoSession(config) + consola.debug("Refreshed Copilot auto-mode session token") + } catch (error) { + consola.error("Failed to refresh auto-mode session token:", error) + } finally { + scheduleAutoSessionRefresh(config) + } + }, refreshIn * 1000) + + if (typeof timer.unref === "function") { + timer.unref() + } +} + +export const enableAutoMode = async ( + config: BridgeConfig, +): Promise => { + const data = await applyAutoSession(config) + runtimeState.autoMode = true + scheduleAutoSessionRefresh(config) + return data +} diff --git a/src/lib/state.ts b/src/lib/state.ts index 8c4ca19..6f8fb8b 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -7,6 +7,10 @@ export interface RuntimeState { rateLimitSeconds?: number rateLimitWait?: boolean lastRequestTimestamp?: number + autoMode?: boolean + autoSessionToken?: string + autoExpiresAt?: number + autoAvailableModels?: Array } export const runtimeState: RuntimeState = {} diff --git a/src/providers/copilot/client.ts b/src/providers/copilot/client.ts index 317c712..35b85c9 100644 --- a/src/providers/copilot/client.ts +++ b/src/providers/copilot/client.ts @@ -3,11 +3,13 @@ import { appendFile } from "node:fs/promises" import type { BridgeConfig } from "~/lib/config" import { BridgeNotImplementedError } from "~/lib/error" +import { runtimeState } from "~/lib/state" const COPILOT_VERSION = "0.26.7" const EDITOR_PLUGIN_VERSION = `copilot-chat/${COPILOT_VERSION}` const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}` const API_VERSION = "2025-04-01" +const AUTO_MODE_API_VERSION = "2025-10-01" const MAX_FETCH_ATTEMPTS = 2 export interface CopilotProviderContext { @@ -45,7 +47,10 @@ const buildHeaders = ( headers.set("editor-plugin-version", EDITOR_PLUGIN_VERSION) headers.set("user-agent", USER_AGENT) headers.set("openai-intent", "conversation-panel") - headers.set("x-github-api-version", API_VERSION) + headers.set( + "x-github-api-version", + runtimeState.autoSessionToken ? AUTO_MODE_API_VERSION : API_VERSION, + ) headers.set("x-request-id", randomUUID()) headers.set("x-vscode-user-agent-library-version", "electron-fetch") @@ -57,6 +62,10 @@ const buildHeaders = ( headers.set("x-initiator", options.initiator) } + if (runtimeState.autoSessionToken) { + headers.set("copilot-session-token", runtimeState.autoSessionToken) + } + if (!headers.has("content-type") && init.body !== undefined) { headers.set("content-type", "application/json") } diff --git a/src/start.ts b/src/start.ts index c58e4de..05b0b8d 100644 --- a/src/start.ts +++ b/src/start.ts @@ -2,6 +2,7 @@ import { defineCommand } from "citty" import consola from "consola" import { setupBridgeAuth } from "~/lib/auth" +import { enableAutoMode } from "~/lib/auto-session" import { applyClaudeConfig, parsePortFromBaseUrl, @@ -116,6 +117,12 @@ export const start = defineCommand({ description: "When --rate-limit is set, wait instead of returning HTTP 429.", }, + auto: { + type: "boolean", + default: false, + description: + "Acquire a Copilot auto-mode session token and attach it to every upstream request (bypasses the router intent step; only auto-mode models are reachable).", + }, }, async run({ args }) { // Port resolution priority (high → low): @@ -167,6 +174,22 @@ export const start = defineCommand({ showToken: args["show-token"], }) + if (args.auto) { + try { + const session = await enableAutoMode(config) + consola.success( + `Auto mode enabled${ + session.available_models?.length ? + ` (available models: ${session.available_models.join(", ")})` + : "" + }`, + ) + } catch (error) { + consola.error("Failed to enable auto mode:", error) + process.exit(1) + } + } + const server = startServer(config) consola.success( @@ -293,7 +316,16 @@ export const start = defineCommand({ .map((id) => getPublicModelId(id)), ) : fallbackModelIds - const finalPickable = pickable.length > 0 ? pickable : fallbackModelIds + const autoAllowed = + args.auto && runtimeState.autoAvailableModels?.length ? + new Set(runtimeState.autoAvailableModels.map(getPublicModelId)) + : undefined + const autoFilteredPickable = + autoAllowed ? pickable.filter((id) => autoAllowed.has(id)) : pickable + const finalPickable = + autoFilteredPickable.length > 0 ? autoFilteredPickable + : pickable.length > 0 ? pickable + : fallbackModelIds if (models.length > 0) { consola.info( `Available models:\n${models