From 0087a923d062ff1adc91d1c0da9afabaa9442296 Mon Sep 17 00:00:00 2001 From: Costas Panay Date: Mon, 20 Jul 2026 02:12:27 -0700 Subject: [PATCH 1/9] feat(opencode): add local Copilot SDK provider Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 660b95b9-9b53-4378-bdc5-efef35aa73a1 --- .../app/src/components/settings-providers.tsx | 2 +- .../src/components/settings-v2/providers.tsx | 2 +- packages/app/src/hooks/use-providers.ts | 1 + packages/core/src/copilot-bridge.ts | 673 ++++++++++++++++++ packages/core/src/plugin/internal.ts | 2 + packages/core/src/plugin/provider.ts | 2 + .../src/plugin/provider/github-copilot-sdk.ts | 183 +++++ packages/core/src/session/runner/llm.ts | 5 + packages/core/src/session/runner/model.ts | 9 +- packages/core/src/tool/registry.ts | 8 + packages/core/src/tool/tool.ts | 7 + packages/core/src/tool/websearch.ts | 47 +- packages/core/test/copilot-bridge.test.ts | 208 ++++++ .../provider-github-copilot-sdk.test.ts | 150 ++++ .../core/test/session-runner-model.test.ts | 46 ++ packages/core/test/session-runner.test.ts | 6 +- packages/core/test/tool-websearch.test.ts | 88 +++ packages/llm/src/route/transport/http.ts | 27 +- packages/llm/src/schema/options.ts | 6 +- packages/llm/test/redirect.test.ts | 39 + packages/llm/test/route.test.ts | 3 + packages/opencode/src/cli/cmd/models.ts | 15 +- packages/opencode/src/provider/provider.ts | 163 ++++- packages/opencode/src/tool/registry.ts | 3 +- packages/opencode/src/tool/websearch.ts | 62 +- .../test/provider/github-copilot-sdk.test.ts | 114 +++ .../session/copilot-sdk-integration.test.ts | 472 ++++++++++++ packages/opencode/test/tool/websearch.test.ts | 11 +- packages/schema/src/provider.ts | 1 + packages/ui/src/components/provider-icon.tsx | 9 +- 30 files changed, 2306 insertions(+), 58 deletions(-) create mode 100644 packages/core/src/copilot-bridge.ts create mode 100644 packages/core/src/plugin/provider/github-copilot-sdk.ts create mode 100644 packages/core/test/copilot-bridge.test.ts create mode 100644 packages/core/test/plugin/provider-github-copilot-sdk.test.ts create mode 100644 packages/llm/test/redirect.test.ts create mode 100644 packages/opencode/test/provider/github-copilot-sdk.test.ts create mode 100644 packages/opencode/test/session/copilot-sdk-integration.test.ts diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index bcd30edbc7de..b59769a02a62 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -83,7 +83,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => return language.t("settings.providers.tag.other") } - const canDisconnect = (item: ProviderItem) => source(item) !== "env" + const canDisconnect = (item: ProviderItem) => source(item) !== "env" && item.id !== "github-copilot-sdk" const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index f945fa33c643..981a13b5f716 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -77,7 +77,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) = return language.t("settings.providers.tag.other") } - const canDisconnect = (item: ProviderItem) => source(item) !== "env" + const canDisconnect = (item: ProviderItem) => source(item) !== "env" && item.id !== "github-copilot-sdk" const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index 982c26ca9712..a0c5605a7d50 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -9,6 +9,7 @@ export const popularProviders = [ "opencode", "opencode-go", "anthropic", + "github-copilot-sdk", "github-copilot", "openai", "google", diff --git a/packages/core/src/copilot-bridge.ts b/packages/core/src/copilot-bridge.ts new file mode 100644 index 000000000000..2b1ff9396152 --- /dev/null +++ b/packages/core/src/copilot-bridge.ts @@ -0,0 +1,673 @@ +export * as CopilotBridge from "./copilot-bridge" + +import { mkdir, readFile, rename, rm, writeFile } from "fs/promises" +import path from "path" +import { Cause, Effect, Exit, Option, Schema } from "effect" +import { ModelV2 } from "./model" +import { ProviderV2 } from "./provider" + +export const providerID = ProviderV2.ID.githubCopilotSDK +export const providerName = "GitHub Copilot SDK" +export const defaultBaseURL = "http://127.0.0.1:4142/v1" +export const defaultModelID = ModelV2.ID.make("gpt-5.6-sol") +export const defaultCatalogTTL = 5 * 60 * 1000 +export const defaultStaleTTL = 7 * 24 * 60 * 60 * 1000 +export const defaultContextLimit = 128_000 +export const defaultOutputLimit = 32_000 + +export class InvalidURLError extends Schema.TaggedErrorClass()("CopilotBridge.InvalidURL", { + url: Schema.String, + message: Schema.String, +}) {} + +export class RedirectError extends Schema.TaggedErrorClass()("CopilotBridge.Redirect", { + url: Schema.String, + status: Schema.Number, +}) { + override get message() { + return `Copilot bridge redirects are not allowed: ${this.url} returned HTTP ${this.status}` + } +} + +export class CatalogUnavailableError extends Schema.TaggedErrorClass()( + "CopilotBridge.CatalogUnavailable", + { + baseURL: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message() { + return `GitHub Copilot SDK model catalog is unavailable at ${this.baseURL}/models` + } +} + +export class ResponseError extends Schema.TaggedErrorClass()("CopilotBridge.ResponseError", { + message: Schema.String, + status: Schema.optional(Schema.Number), +}) {} + +export interface Model { + readonly id: ModelV2.ID + readonly name: string + readonly family: string + readonly releaseDate: string + readonly contextLimit: number + readonly inputLimit?: number + readonly outputLimit: number + readonly reasoningEfforts: readonly string[] + readonly defaultReasoningEffort?: string + readonly tools: boolean + readonly vision: boolean + readonly structuredOutput: boolean +} + +const Model = Schema.Struct({ + id: ModelV2.ID, + name: Schema.String, + family: Schema.String, + releaseDate: Schema.String, + contextLimit: Schema.Number, + inputLimit: Schema.optional(Schema.Number), + outputLimit: Schema.Number, + reasoningEfforts: Schema.Array(Schema.String), + defaultReasoningEffort: Schema.optional(Schema.String), + tools: Schema.Boolean, + vision: Schema.Boolean, + structuredOutput: Schema.Boolean, +}) + +const Cache = Schema.Struct({ + version: Schema.Literal(1), + baseURL: Schema.String, + fetchedAt: Schema.Number, + models: Schema.Array(Model), +}) +type Cache = Schema.Schema.Type + +const List = Schema.Struct({ + data: Schema.Array(Schema.Unknown), +}) + +export interface CatalogResult { + readonly models: readonly Model[] + readonly source: "remote" | "fresh-cache" | "stale-cache" | "fallback" + readonly fetchedAt: number +} + +export interface CatalogInput { + readonly baseURL?: string + readonly allowedPorts?: readonly number[] + readonly cacheFile: string + readonly cacheTTL?: number + readonly staleTTL?: number + readonly fallbackModels?: readonly string[] + readonly refresh?: boolean + readonly defaults?: { + readonly contextLimit?: number + readonly outputLimit?: number + readonly tools?: boolean + readonly vision?: boolean + readonly structuredOutput?: boolean + } +} + +export interface SearchInput { + readonly baseURL?: string + readonly allowedPorts?: readonly number[] + readonly model: string + readonly query: string + readonly cacheKey?: string + readonly signal?: AbortSignal +} + +export interface Citation { + readonly url: string + readonly title?: string +} + +export interface SearchResult { + readonly text: string + readonly citations: readonly Citation[] + readonly responseID?: string +} + +export type Fetch = (request: string | URL | Request, init?: RequestInit) => Promise + +export function baseURL(input = defaultBaseURL, allowedPorts?: readonly number[]) { + if (!URL.canParse(input)) throw new InvalidURLError({ url: input, message: "URL is not valid" }) + const url = new URL(input) + if (url.protocol !== "http:") { + throw new InvalidURLError({ url: input, message: "Only HTTP loopback URLs are supported" }) + } + if (url.username || url.password) { + throw new InvalidURLError({ url: input, message: "URL userinfo is not allowed" }) + } + if (url.search || url.hash) { + throw new InvalidURLError({ url: input, message: "URL query strings and fragments are not allowed" }) + } + if (!loopback(url.hostname)) { + throw new InvalidURLError({ url: input, message: "Host must be a literal loopback address" }) + } + if (url.pathname.replace(/\/+$/, "") !== "/v1") { + throw new InvalidURLError({ url: input, message: "Base URL path must be /v1" }) + } + const port = Number(url.port || "80") + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new InvalidURLError({ url: input, message: "Port must be between 1 and 65535" }) + } + if (allowedPorts?.length && !allowedPorts.includes(port)) { + throw new InvalidURLError({ url: input, message: `Port ${port} is not allowed by provider policy` }) + } + return `${url.origin}/v1` +} + +export function allowedPorts(input: unknown) { + const values = Array.isArray(input) + ? input + : typeof input === "string" + ? input + .split(",") + .map((item) => Number(item.trim())) + .filter((item) => Number.isFinite(item)) + : [] + if (!values.length) return undefined + const result = [ + ...new Set(values.filter((item): item is number => Number.isInteger(item) && item > 0 && item <= 65_535)), + ] + return result.length ? result : undefined +} + +export function transport(input = defaultBaseURL, ports?: readonly number[]): Fetch { + const root = baseURL(input, ports) + const origin = new URL(root).origin + return async (request, init) => { + const target = new URL(request instanceof Request ? request.url : request) + if (target.origin !== origin || !["/v1/models", "/v1/responses"].includes(target.pathname)) { + throw new InvalidURLError({ url: target.toString(), message: "Request escaped the Copilot bridge API" }) + } + if (target.username || target.password || target.search || target.hash) { + throw new InvalidURLError({ url: target.toString(), message: "Request URL contains forbidden components" }) + } + + const incoming = new Headers(init?.headers ?? (request instanceof Request ? request.headers : undefined)) + const headers = new Headers() + for (const name of ["accept", "content-type", "user-agent"]) { + const value = incoming.get(name) + if (value) headers.set(name, value) + } + + const response = await fetch(request, { + ...init, + body: target.pathname === "/v1/responses" ? responsesBody(init?.body) : init?.body, + headers, + credentials: "omit", + redirect: "manual", + referrerPolicy: "no-referrer", + }) + if (response.status >= 300 && response.status < 400) { + throw new RedirectError({ url: target.toString(), status: response.status }) + } + return target.pathname === "/v1/responses" ? normalizeResponse(response) : response + } +} + +function responsesBody(input: RequestInit["body"]) { + if (typeof input !== "string") return input + const decoded = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(input)) + if (!isRecord(decoded) || !Array.isArray(decoded.input)) return input + return JSON.stringify({ + ...decoded, + input: decoded.input.map((item) => { + if (!isRecord(item) || item.type !== undefined || typeof item.role !== "string") return item + return { type: "message", ...item } + }), + }) +} + +function normalizeResponse(response: Response) { + if (!response.body || !response.headers.get("content-type")?.includes("text/event-stream")) return response + const reader = response.body.getReader() + const decoder = new TextDecoder() + const encoder = new TextEncoder() + const state = { + added: new Set(), + responseID: undefined as string | undefined, + nextOutputIndex: 0, + } + let buffer = "" + const pending: Uint8Array[] = [] + let done = false + + const frames = () => { + const parts = buffer.split(/\r?\n\r?\n/) + buffer = parts.pop() ?? "" + for (const frame of parts) { + const normalized = normalizeFrame(frame, state) + if (normalized) pending.push(encoder.encode(`${normalized}\n\n`)) + } + } + + const body = new ReadableStream({ + async pull(controller) { + while (!pending.length && !done) { + const part = await reader.read() + if (!part.done) { + buffer += decoder.decode(part.value, { stream: true }) + frames() + continue + } + buffer += decoder.decode() + if (buffer.trim()) pending.push(encoder.encode(`${normalizeFrame(buffer, state)}\n\n`)) + buffer = "" + done = true + } + const next = pending.shift() + if (next) { + controller.enqueue(next) + return + } + controller.close() + }, + async cancel(reason) { + await reader.cancel(reason) + }, + }) + const headers = new Headers(response.headers) + headers.delete("content-length") + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} + +function normalizeFrame(frame: string, state: { added: Set; responseID?: string; nextOutputIndex: number }) { + const data = frame + .split(/\r?\n/) + .filter((line) => line.startsWith("data: ")) + .map((line) => line.slice(6)) + .join("\n") + if (!data || data === "[DONE]") return frame + const event = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(data)) + if (!isRecord(event)) return frame + if (event.type === "response.created") { + state.responseID = string(record(event.response)?.id) + return frame + } + const item = record(event.item) + if (event.type === "response.output_item.added" && typeof item?.id === "string") { + state.added.add(item.id) + const outputIndex = finite(event.output_index) + if (outputIndex !== undefined) state.nextOutputIndex = Math.max(state.nextOutputIndex, outputIndex + 1) + return frame + } + if ( + event.type !== "response.output_item.done" || + item?.type !== "function_call" || + typeof item.id !== "string" || + state.added.has(item.id) + ) { + return frame + } + + state.added.add(item.id) + const outputIndex = finite(event.output_index) ?? state.nextOutputIndex++ + const args = string(item.arguments) ?? "" + const base = { + response_id: string(event.response_id) ?? state.responseID, + output_index: outputIndex, + } + return [ + sse("response.output_item.added", { + ...base, + item: { ...item, status: "in_progress", arguments: "" }, + }), + sse("response.function_call_arguments.delta", { + ...base, + item_id: item.id, + delta: args, + }), + sse("response.function_call_arguments.done", { + ...base, + item_id: item.id, + arguments: args, + }), + sse("response.output_item.done", { + ...base, + item, + }), + ].join("\n\n") +} + +function sse(type: string, input: Record) { + return `event: ${type}\ndata: ${JSON.stringify({ type, ...input })}` +} + +export const catalog = Effect.fn("CopilotBridge.catalog")(function* (input: CatalogInput) { + const root = baseURL(input.baseURL, input.allowedPorts) + const cached = yield* readCache(input.cacheFile, root) + const now = Date.now() + const cacheTTL = duration(input.cacheTTL, defaultCatalogTTL) + const staleTTL = duration(input.staleTTL, defaultStaleTTL) + if (!input.refresh && cached && now - cached.fetchedAt <= cacheTTL) { + return { models: cached.models, source: "fresh-cache", fetchedAt: cached.fetchedAt } satisfies CatalogResult + } + + const remote = yield* fetchCatalog(root, input).pipe(Effect.exit) + if (Exit.isSuccess(remote)) { + const next = { + version: 1 as const, + baseURL: root, + fetchedAt: now, + models: remote.value, + } + yield* writeCache(input.cacheFile, next).pipe( + Effect.catch((cause) => Effect.logWarning("failed to cache Copilot bridge models", { cause })), + ) + return { models: next.models, source: "remote", fetchedAt: next.fetchedAt } satisfies CatalogResult + } + + if (cached && now - cached.fetchedAt <= staleTTL) { + yield* Effect.logWarning("using stale Copilot bridge model catalog", { + baseURL: root, + ageMs: now - cached.fetchedAt, + cause: remote.cause, + }) + return { models: cached.models, source: "stale-cache", fetchedAt: cached.fetchedAt } satisfies CatalogResult + } + + const fallback = (input.fallbackModels ?? []).map((id) => normalizeModel({ id }, input)) + if (fallback.length) { + yield* Effect.logWarning("using explicitly configured Copilot bridge fallback models", { + baseURL: root, + models: fallback.map((model) => model.id), + cause: remote.cause, + }) + return { models: fallback, source: "fallback", fetchedAt: now } satisfies CatalogResult + } + + return yield* new CatalogUnavailableError({ baseURL: root, cause: Cause.squash(remote.cause) }) +}) + +export const clearCatalog = Effect.fn("CopilotBridge.clearCatalog")(function* (cacheFile: string) { + yield* Effect.tryPromise({ + try: () => rm(cacheFile, { force: true }), + catch: (cause) => cause, + }) +}) + +export const search = Effect.fn("CopilotBridge.search")(function* (input: SearchInput) { + const root = baseURL(input.baseURL, input.allowedPorts) + const response = yield* Effect.tryPromise({ + try: (signal) => + transport(root, input.allowedPorts)(`${root}/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: input.model, + instructions: + "You MUST use the enabled Copilot web_search tool before answering. Return a concise factual answer and preserve every source URL.", + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: input.query }], + }, + ], + tools: [{ type: "web_search_preview" }], + tool_choice: "required", + stream: true, + store: false, + ...(input.cacheKey ? { prompt_cache_key: input.cacheKey } : {}), + }), + signal: combineSignals(signal, input.signal), + }), + catch: (cause) => cause, + }) + if (!response.ok) { + const message = yield* Effect.promise(() => response.text()) + return yield* new ResponseError({ + status: response.status, + message: message || `Copilot bridge search failed with HTTP ${response.status}`, + }) + } + const body = yield* Effect.promise(() => response.text()) + return yield* parseSearch(body) +}) + +export const parseSearch = Effect.fn("CopilotBridge.parseSearch")(function* (body: string) { + const events = body + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => line.slice(6).trim()) + .filter((line) => line && line !== "[DONE]") + .flatMap((line) => + Option.match(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(line), { + onNone: () => [], + onSome: (value) => [value], + }), + ) + .filter(isRecord) + + const failed = events.find((event) => event.type === "response.failed") + if (failed) { + const response = record(failed.response) + const error = record(response?.error) + return yield* new ResponseError({ + message: string(error?.message) ?? "Copilot bridge search failed", + }) + } + + const deltas = events + .filter((event) => event.type === "response.output_text.delta") + .map((event) => string(event.delta) ?? "") + .join("") + const completed = events + .filter((event) => event.type === "response.output_item.done") + .flatMap((event) => { + const item = record(event.item) + if (item?.type !== "message" || !Array.isArray(item.content)) return [] + return item.content.flatMap((part) => { + const value = record(part) + return value?.type === "output_text" && typeof value.text === "string" ? [value.text] : [] + }) + }) + .join("") + const text = deltas || completed + const responseID = events.flatMap((event) => { + const response = record(event.response) + return typeof response?.id === "string" ? [response.id] : [] + })[0] + return { + text, + citations: citations(events, text), + responseID, + } satisfies SearchResult +}) + +function loopback(hostname: string) { + if (hostname === "[::1]") return true + const octets = hostname.split(".").map(Number) + return ( + octets.length === 4 && + octets[0] === 127 && + octets.every((value) => Number.isInteger(value) && value >= 0 && value <= 255) + ) +} + +function duration(input: number | undefined, fallback: number) { + return typeof input === "number" && Number.isFinite(input) && input >= 0 ? input : fallback +} + +function combineSignals(effect: AbortSignal, external: AbortSignal | undefined) { + if (!external) return effect + return AbortSignal.any([effect, external]) +} + +function isRecord(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input) +} + +function record(input: unknown) { + return isRecord(input) ? input : undefined +} + +function string(input: unknown) { + return typeof input === "string" ? input : undefined +} + +function finite(input: unknown) { + return typeof input === "number" && Number.isFinite(input) ? input : undefined +} + +function bool(input: unknown) { + return typeof input === "boolean" ? input : undefined +} + +function strings(input: unknown) { + return Array.isArray(input) ? input.filter((item): item is string => typeof item === "string") : undefined +} + +function family(id: string, input: unknown) { + if (typeof input === "string" && input) return input + if (id.includes("claude")) return "claude" + if (id.includes("gemini")) return "gemini" + if (id.includes("gpt")) return "gpt" + if (id.includes("kimi")) return "kimi" + return id.split(/[-/]/)[0] ?? id +} + +function inferredEfforts(id: string) { + if (id.includes("gemini")) return ["low", "medium", "high"] + if (id.includes("gpt-5") || id.includes("claude")) return ["low", "medium", "high", "xhigh"] + return [] +} + +function releaseDate(id: string, version: unknown) { + if (typeof version !== "string") return "" + const value = version.startsWith(`${id}-`) ? version.slice(id.length + 1) : version + return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : "" +} + +function normalizeModel(input: unknown, defaults: CatalogInput): Model { + const item = record(input) ?? {} + const id = string(item.id) ?? "" + const capabilities = record(item.capabilities) + const supports = record(capabilities?.supports) + const limits = record(capabilities?.limits) + const efforts = + strings(item.supportedReasoningEfforts) ?? + strings(item.supported_reasoning_efforts) ?? + strings(supports?.reasoning_effort) ?? + inferredEfforts(id) + const defaultEffort = string(item.defaultReasoningEffort) ?? string(item.default_reasoning_effort) + return { + id: ModelV2.ID.make(id), + name: string(item.name) ?? id, + family: family(id, capabilities?.family), + releaseDate: releaseDate(id, item.version), + contextLimit: + finite(limits?.max_context_window_tokens) ?? + finite(limits?.max_prompt_tokens) ?? + defaults.defaults?.contextLimit ?? + defaultContextLimit, + inputLimit: finite(limits?.max_prompt_tokens), + outputLimit: finite(limits?.max_output_tokens) ?? defaults.defaults?.outputLimit ?? defaultOutputLimit, + reasoningEfforts: [...new Set(efforts)], + defaultReasoningEffort: defaultEffort && efforts.includes(defaultEffort) ? defaultEffort : undefined, + tools: bool(supports?.tool_calls) ?? defaults.defaults?.tools ?? true, + vision: bool(supports?.vision) ?? defaults.defaults?.vision ?? true, + structuredOutput: bool(supports?.structured_outputs) ?? defaults.defaults?.structuredOutput ?? true, + } +} + +function fetchCatalog(root: string, input: CatalogInput) { + return Effect.gen(function* () { + const response = yield* Effect.tryPromise({ + try: (signal) => + transport(root, input.allowedPorts)(`${root}/models`, { + headers: { accept: "application/json" }, + signal: AbortSignal.any([signal, AbortSignal.timeout(5_000)]), + }), + catch: (cause) => cause, + }) + if (!response.ok) { + return yield* new CatalogUnavailableError({ baseURL: root, cause: new Error(`HTTP ${response.status}`) }) + } + const raw = yield* Effect.tryPromise({ + try: () => response.json(), + catch: (cause) => cause, + }) + const list = yield* Schema.decodeUnknownEffect(List)(raw).pipe( + Effect.mapError((cause) => new CatalogUnavailableError({ baseURL: root, cause })), + ) + const models = list.data + .map((item) => normalizeModel(item, input)) + .filter((model) => model.id.length > 0) + .filter((model) => { + const raw = list.data.find((item) => record(item)?.id === model.id) + const state = string(record(record(raw)?.policy)?.state) + return state !== "disabled" && state !== "unconfigured" + }) + if (!models.length) { + return yield* new CatalogUnavailableError({ baseURL: root, cause: new Error("No enabled models returned") }) + } + return models + }) +} + +function readCache(cacheFile: string, root: string) { + return Effect.gen(function* () { + const raw = yield* Effect.tryPromise({ + try: () => readFile(cacheFile, "utf8"), + catch: (cause) => cause, + }).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (raw === undefined) return undefined + const parsed = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.fromJsonString(Cache))(raw)) + if (!parsed || parsed.baseURL !== root) return undefined + return parsed + }) +} + +function writeCache(cacheFile: string, cache: Cache) { + return Effect.tryPromise({ + try: async () => { + await mkdir(path.dirname(cacheFile), { recursive: true }) + const temporary = `${cacheFile}.${process.pid}.tmp` + await writeFile(temporary, `${JSON.stringify(cache, null, 2)}\n`, { mode: 0o600 }) + await rename(temporary, cacheFile) + }, + catch: (cause) => cause, + }) +} + +function citations(events: readonly Record[], text: string) { + const result = new Map() + const add = (url: unknown, title?: unknown) => { + if (typeof url !== "string" || !URL.canParse(url)) return + const parsed = new URL(url) + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return + const current = result.get(parsed.toString()) + result.set(parsed.toString(), { + url: parsed.toString(), + title: typeof title === "string" && title ? title : current?.title, + }) + } + + for (const event of events) { + const item = record(event.item) + if (!Array.isArray(item?.content)) continue + for (const part of item.content) { + const annotations = record(part)?.annotations + if (!Array.isArray(annotations)) continue + for (const annotation of annotations) { + const value = record(annotation) + if (value?.type !== "url_citation") continue + add(value.url, value.title) + } + } + } + for (const match of text.matchAll(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g)) add(match[2], match[1]) + for (const match of text.matchAll(/https?:\/\/[^\s<>()]+/g)) add(match[0]?.replace(/[.,;:!?]+$/, "")) + return [...result.values()] +} diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index d4ab71cb6b04..804d16372182 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -31,6 +31,7 @@ import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" +import { GithubCopilotSDKSecurityPlugin } from "./provider/github-copilot-sdk" import { SkillPlugin } from "./skill" import { VariantPlugin } from "./variant" @@ -118,6 +119,7 @@ const layer = Layer.effectDiscard( for (const item of ProviderPlugins) yield* add(item) yield* add(ConfigExternalPlugin.Plugin) yield* add(ConfigProviderPlugin.Plugin) + yield* add(GithubCopilotSDKSecurityPlugin) yield* add(VariantPlugin.Plugin) }), ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 1749b474ed33..1e85fedf6cf8 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -10,6 +10,7 @@ import { DeepInfraPlugin } from "./provider/deepinfra" import { DynamicProviderPlugin } from "./provider/dynamic" import { GatewayPlugin } from "./provider/gateway" import { GithubCopilotPlugin } from "./provider/github-copilot" +import { GithubCopilotSDKPlugin } from "./provider/github-copilot-sdk" import { GitLabPlugin } from "./provider/gitlab" import { GooglePlugin } from "./provider/google" import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./provider/google-vertex" @@ -46,6 +47,7 @@ export const ProviderPlugins: PluginInternal.Plugin + Effect.logWarning("GitHub Copilot SDK bridge is unavailable", { + baseURL: connection.baseURL, + cause, + }).pipe(Effect.as(undefined)), + ), + ) + if (!result) return + + catalog.provider.update(CopilotBridge.providerID, (provider) => { + provider.name = CopilotBridge.providerName + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai", + url: connection.baseURL, + settings: { redirect: "error" }, + } + provider.request.headers = {} + provider.request.body = {} + }) + + for (const remote of result.models) { + catalog.model.update(CopilotBridge.providerID, remote.id, (model) => { + model.name = remote.name + model.family = ModelV2.Family.make(remote.family) + model.api = { + id: remote.id, + type: "aisdk", + package: "@ai-sdk/openai", + url: connection.baseURL, + settings: { redirect: "error" }, + } + model.capabilities = { + tools: remote.tools, + input: remote.vision ? ["text", "image"] : ["text"], + output: ["text"], + } + model.variants = remote.reasoningEfforts.map((effort) => ({ + id: ModelV2.VariantID.make(effort), + headers: {}, + body: { reasoning: { effort } }, + })) + model.time.released = Date.parse(remote.releaseDate) || 0 + model.cost = [{ input: 0, output: 0, cache: { read: 0, write: 0 } }] + model.status = "active" + model.enabled = true + model.limit = { + context: remote.contextLimit, + input: remote.inputLimit, + output: remote.outputLimit, + } + }) + } + }), + ) + + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { + if (evt.model.providerID !== CopilotBridge.providerID) return + evt.language = evt.sdk.responses(evt.model.api.id) + }), + ) + }), +}) + +export const GithubCopilotSDKSecurityPlugin = define({ + id: "github-copilot-sdk-security", + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + yield* ctx.catalog.transform( + Effect.fn(function* (catalog) { + const item = catalog.provider.get(CopilotBridge.providerID) + if (!item) return + const connection = resolveConnection( + Config.latest(yield* config.entries(), "providers")?.[CopilotBridge.providerID], + ) + catalog.provider.update(CopilotBridge.providerID, (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai", + url: connection.baseURL, + settings: { redirect: "error" }, + } + provider.request.headers = {} + for (const key of deploymentFields) delete provider.request.body[key] + }) + for (const model of item.models.values()) { + catalog.model.update(CopilotBridge.providerID, model.id, (draft) => { + draft.api = { + id: draft.api.id, + type: "aisdk", + package: "@ai-sdk/openai", + url: connection.baseURL, + settings: { redirect: "error" }, + } + draft.request.headers = {} + for (const key of deploymentFields) delete draft.request.body[key] + }) + } + }), + ) + }), +}) + +function resolveConnection(provider: ConfigProvider.Info | undefined) { + const body = provider?.request?.body + const settings = provider?.api?.settings + const configuredURL = + provider?.api?.url ?? + (typeof body?.baseURL === "string" ? body.baseURL : undefined) ?? + process.env.OPENCODE_COPILOT_BRIDGE_URL ?? + CopilotBridge.defaultBaseURL + const allowedPorts = CopilotBridge.allowedPorts( + settings?.allowedPorts ?? body?.allowedPorts ?? process.env.OPENCODE_COPILOT_BRIDGE_ALLOWED_PORTS, + ) + const fallbackModels = settings?.fallbackModels ?? body?.fallbackModels + return { + baseURL: CopilotBridge.baseURL(configuredURL, allowedPorts), + allowedPorts, + cacheFile: + typeof settings?.catalogCacheFile === "string" + ? settings.catalogCacheFile + : typeof body?.catalogCacheFile === "string" + ? body.catalogCacheFile + : undefined, + cacheTTL: + typeof settings?.catalogCacheTTL === "number" + ? settings.catalogCacheTTL + : typeof body?.catalogCacheTTL === "number" + ? body.catalogCacheTTL + : undefined, + staleTTL: + typeof settings?.catalogStaleTTL === "number" + ? settings.catalogStaleTTL + : typeof body?.catalogStaleTTL === "number" + ? body.catalogStaleTTL + : undefined, + fallbackModels: Array.isArray(fallbackModels) + ? fallbackModels.filter((item: unknown): item is string => typeof item === "string") + : undefined, + } +} diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 72c761e10d93..0b467e3a48b5 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -253,6 +253,11 @@ const layer = Layer.effect( sessionID: session.id, agent: agent.id, assistantMessageID, + model: { + providerID: ProviderV2.ID.make(model.provider), + modelID: ModelV2.ID.make(model.id), + baseURL: model.route.endpoint.baseURL, + }, call: event, }), ).pipe( diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 74e78120c20e..553a360cb4be 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -14,6 +14,7 @@ import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" import { SessionSchema } from "../schema" +import { CopilotBridge } from "../../copilot-bridge" export class ModelNotSelectedError extends Schema.TaggedErrorClass()( "SessionRunnerModel.ModelNotSelectedError", @@ -92,11 +93,15 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { const httpBody = Object.hasOwn(body, "apiKey") ? Object.fromEntries(Object.entries(body).filter(([key]) => key !== "apiKey")) : body + const redirect = model.api.settings?.redirect return route.with({ provider: model.providerID, endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url }, headers: model.request.headers, - http: { body: httpBody }, + http: { + body: httpBody, + ...(redirect === "follow" || redirect === "error" || redirect === "manual" ? { redirect } : {}), + }, limits: { context: model.limit.context, output: model.limit.output }, }) } @@ -138,7 +143,7 @@ export const fromCatalogModel = ( : produce(model, (draft) => { Object.assign(draft.request.body, credential.metadata) }) - const key = apiKey(resolved, credential) + const key = resolved.providerID === CopilotBridge.providerID ? undefined : apiKey(resolved, credential) if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") { return Effect.succeed( withDefaults(resolved, OpenAIResponses.route) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 1c2dfe7ab459..ff5e5bcc9214 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -12,11 +12,18 @@ import { ApplicationTools } from "./application-tools" import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool" import { Tools } from "./tools" import { makeLocationNode } from "../effect/app-node" +import type { ModelV2 } from "../model" +import type { ProviderV2 } from "../provider" export type ExecuteInput = { readonly sessionID: SessionSchema.ID readonly agent: AgentV2.ID readonly assistantMessageID: SessionMessage.ID + readonly model?: { + readonly providerID: ProviderV2.ID + readonly modelID: ModelV2.ID + readonly baseURL?: string + } readonly call: ToolCall } @@ -64,6 +71,7 @@ const registryLayer = Layer.effect( agent: input.agent, assistantMessageID: input.assistantMessageID, toolCallID: input.call.id, + model: input.model, }).pipe( Effect.map((output) => ({ output })), Effect.catchTag("LLM.ToolFailure", (failure) => diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 1d9a82e9522d..d9ac94d35041 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -5,12 +5,19 @@ import { Effect, JsonSchema, Schema } from "effect" import type { AgentV2 } from "../agent" import type { SessionMessage } from "../session/message" import type { SessionSchema } from "../session/schema" +import type { ModelV2 } from "../model" +import type { ProviderV2 } from "../provider" export interface Context { readonly sessionID: SessionSchema.ID readonly agent: AgentV2.ID readonly assistantMessageID: SessionMessage.ID readonly toolCallID: string + readonly model?: { + readonly providerID: ProviderV2.ID + readonly modelID: ModelV2.ID + readonly baseURL?: string + } } export type SchemaType = Schema.Codec diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 6d62236316ab..a75ee9b94bfe 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -14,6 +14,7 @@ import { Tools } from "./tools" import { collectBoundedResponseBody } from "./http-body" import { checksum } from "../util/encode" import { ToolRegistry } from "./registry" +import { CopilotBridge } from "../copilot-bridge" export const name = "websearch" export const NO_RESULTS = "No search results found. Please try a different query." @@ -24,14 +25,14 @@ export const MAX_CONTEXT_CHARACTERS = 50_000 export const MAX_RESPONSE_BYTES = 256 * 1024 /** - * Provider-independent local web search retained in V2 core for launch parity. - * This invokes the legacy Exa/Parallel product backends itself. It is distinct - * from provider-hosted web search tools, which remain route-owned and execute - * at the model provider. Ownership of this compromise can be revisited later. + * OpenCode-owned web search retained in V2 core for launch parity. Exa and + * Parallel remain provider-independent backends; the Copilot SDK provider uses + * a search-only bridge request while OpenCode still owns tool authorization, + * execution, persistence, and continuation. */ -export const description = `Search the web using the session's local web search provider. Use this for current information beyond knowledge cutoff. +export const description = `Search the web using the session's configured web search provider. Use this for current information beyond knowledge cutoff. -This is a provider-independent local tool backed by Exa or Parallel. Provider-hosted web search tools are separate and execute at the model provider. +This is an OpenCode tool. When GitHub Copilot SDK is selected, only Copilot's hosted web search runs behind the local bridge; all tool orchestration remains in OpenCode. Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters. @@ -56,7 +57,7 @@ export const Input = Schema.Struct({ ), }) -export const Provider = Schema.Literals(["exa", "parallel"]) +export const Provider = Schema.Literals(["exa", "parallel", "github-copilot-sdk"]) export type Provider = typeof Provider.Type export interface Config { @@ -73,7 +74,9 @@ export class ConfigService extends Context.Service()("@op export const defaultConfigLayer = Layer.sync(ConfigService, () => ConfigService.of({ provider: - process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel" + process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || + process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel" || + process.env.OPENCODE_WEBSEARCH_PROVIDER === "github-copilot-sdk" ? process.env.OPENCODE_WEBSEARCH_PROVIDER : undefined, enableExa: truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA"), @@ -89,7 +92,9 @@ export function selectProvider( sessionID: string, flags: Pick = { enableExa: false, enableParallel: false }, override?: Provider, + modelProviderID?: string, ): Provider { + if (modelProviderID === CopilotBridge.providerID) return "github-copilot-sdk" if (override) return override if (flags.enableParallel) return "parallel" if (flags.enableExa) return "exa" @@ -187,6 +192,14 @@ const callMcp = ( const Output = Schema.Struct({ provider: Provider, text: Schema.String, + citations: Schema.optional( + Schema.Array( + Schema.Struct({ + url: Schema.String, + title: Schema.optional(Schema.String), + }), + ), + ), }) const layer = Layer.effectDiscard( @@ -204,7 +217,7 @@ const layer = Layer.effectDiscard( output: Output, toModelOutput: ({ output }) => [{ type: "text", text: output.text }], execute: (input, context) => { - const provider = selectProvider(context.sessionID, config, config.provider) + const provider = selectProvider(context.sessionID, config, config.provider, context.model?.providerID) return Effect.gen(function* () { yield* permission.assert({ action: name, @@ -216,6 +229,21 @@ const layer = Layer.effectDiscard( source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) + if (provider === "github-copilot-sdk") { + if (!context.model?.baseURL) return yield* Effect.fail(new Error("Copilot bridge URL is unavailable")) + const result = yield* CopilotBridge.search({ + baseURL: context.model.baseURL, + model: context.model.modelID, + query: input.query, + cacheKey: `websearch:${context.sessionID}:${context.toolCallID}`, + }) + return { + provider, + text: result.text || NO_RESULTS, + citations: result.citations, + } + } + const text = provider === "exa" ? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, { @@ -234,7 +262,6 @@ const layer = Layer.effectDiscard( objective: input.query, search_queries: [input.query], session_id: context.sessionID, - // V2 invocation context does not safely expose the model yet. }, { "User-Agent": `opencode/${InstallationVersion}`, diff --git a/packages/core/test/copilot-bridge.test.ts b/packages/core/test/copilot-bridge.test.ts new file mode 100644 index 000000000000..4e2471f28144 --- /dev/null +++ b/packages/core/test/copilot-bridge.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, test } from "bun:test" +import path from "path" +import os from "os" +import { rm } from "fs/promises" +import { Effect } from "effect" +import { CopilotBridge } from "../src/copilot-bridge" + +const servers: Bun.Server[] = [] +const files: string[] = [] + +afterEach(async () => { + servers.splice(0).forEach((server) => server.stop(true)) + await Promise.all(files.splice(0).map((file) => rm(file, { force: true }))) +}) + +function serve(fetch: (request: Request) => Response | Promise) { + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch }) + servers.push(server) + return `http://127.0.0.1:${server.port}/v1` +} + +function cache() { + const file = path.join(os.tmpdir(), `copilot-bridge-${crypto.randomUUID()}.json`) + files.push(file) + return file +} + +describe("CopilotBridge.baseURL", () => { + test("accepts only literal HTTP loopback /v1 endpoints", () => { + expect(CopilotBridge.baseURL("http://127.0.0.1:4142/v1/")).toBe("http://127.0.0.1:4142/v1") + expect(() => CopilotBridge.baseURL("https://127.0.0.1:4142/v1")).toThrow() + expect(() => CopilotBridge.baseURL("http://localhost:4142/v1")).toThrow() + expect(() => CopilotBridge.baseURL("http://127.0.0.1.example.com:4142/v1")).toThrow() + expect(() => CopilotBridge.baseURL("http://user@127.0.0.1:4142/v1")).toThrow() + expect(() => CopilotBridge.baseURL("http://127.0.0.1:4142/other")).toThrow() + expect(() => CopilotBridge.baseURL("http://127.0.0.1:4142/v1", [4141])).toThrow() + }) +}) + +describe("CopilotBridge.transport", () => { + test("strips credentials and rejects redirects", async () => { + let received: Headers | undefined + let body: unknown + const root = serve(async (request) => { + received = request.headers + if (request.method === "POST") body = await request.json() + return Response.json({ object: "list", data: [] }) + }) + const fetch = CopilotBridge.transport(root) + await fetch(`${root}/models`, { + headers: { + accept: "application/json", + authorization: "Bearer secret", + cookie: "session=secret", + "x-api-key": "secret", + "x-custom": "secret", + }, + }) + expect(received?.get("accept")).toBe("application/json") + expect(received?.get("authorization")).toBeNull() + expect(received?.get("cookie")).toBeNull() + expect(received?.get("x-api-key")).toBeNull() + expect(received?.get("x-custom")).toBeNull() + + await fetch(`${root}/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }], + }), + }) + expect(body).toEqual({ + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }], + }) + + const redirect = serve(() => Response.redirect("https://example.com/collect", 302)) + expect(CopilotBridge.transport(redirect)(`${redirect}/models`)).rejects.toBeInstanceOf(CopilotBridge.RedirectError) + }) + + test("synthesizes complete, ordered lifecycle events for terminal-only parallel tool calls", async () => { + const root = serve( + () => + new Response( + [ + 'event: response.created\ndata: {"type":"response.created","response":{"id":"resp_tools"}}', + 'event: response.output_item.done\ndata: {"type":"response.output_item.done","item":{"id":"fc_1","type":"function_call","status":"completed","call_id":"call_1","name":"first","arguments":"{}"}}', + 'event: response.output_item.done\ndata: {"type":"response.output_item.done","item":{"id":"fc_2","type":"function_call","status":"completed","call_id":"call_2","name":"second","arguments":"{}"}}', + 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_tools","status":"completed"}}', + "", + ].join("\n\n"), + { headers: { "content-type": "text/event-stream" } }, + ), + ) + const response = await CopilotBridge.transport(root)(`${root}/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"input":[]}', + }) + const output = await response.text() + const added = output + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line): unknown => JSON.parse(line.slice(6))) + .filter( + (event): event is Record => + typeof event === "object" && event !== null && !Array.isArray(event), + ) + .filter((event) => event.type === "response.output_item.added") + + expect(added.map((event) => event.output_index)).toEqual([0, 1]) + expect(output).toContain("response.function_call_arguments.delta") + expect(output).toContain("response.function_call_arguments.done") + }) +}) + +describe("CopilotBridge.catalog", () => { + test("discovers minimal bridge models and reuses a stale cache", async () => { + const root = serve(() => + Response.json({ + object: "list", + data: [{ id: "gpt-5.6-sol", object: "model", owned_by: "github-copilot" }], + }), + ) + const cacheFile = cache() + const remote = await Effect.runPromise(CopilotBridge.catalog({ baseURL: root, cacheFile })) + expect(remote.source).toBe("remote") + expect(remote.models[0]).toMatchObject({ + id: "gpt-5.6-sol", + contextLimit: 128_000, + outputLimit: 32_000, + tools: true, + vision: true, + }) + + servers.splice(0).forEach((server) => server.stop(true)) + const stale = await Effect.runPromise( + CopilotBridge.catalog({ baseURL: root, cacheFile, cacheTTL: 0, staleTTL: 60_000 }), + ) + expect(stale.source).toBe("stale-cache") + expect(stale.models.map((model) => String(model.id))).toEqual(["gpt-5.6-sol"]) + }) + + test("uses pinned fallback models only when explicitly configured", async () => { + const root = serve(() => new Response("offline", { status: 503 })) + const result = await Effect.runPromise( + CopilotBridge.catalog({ + baseURL: root, + cacheFile: cache(), + fallbackModels: ["gpt-5.6-sol"], + }), + ) + expect(result.source).toBe("fallback") + expect(String(result.models[0]?.id)).toBe("gpt-5.6-sol") + }) + + test("fails visibly when no bridge, cache, or explicit fallback is available", async () => { + const root = serve(() => new Response("offline", { status: 503 })) + const failure = await Effect.runPromise( + CopilotBridge.catalog({ baseURL: root, cacheFile: cache() }).pipe(Effect.flip), + ) + expect(failure).toBeInstanceOf(CopilotBridge.CatalogUnavailableError) + expect(failure.message).toContain("/models") + }) +}) + +describe("CopilotBridge.search", () => { + test("sends a search-only Responses request and preserves citations", async () => { + let body: Record | undefined + const root = serve(async (request) => { + const value: unknown = await request.json() + body = + typeof value === "object" && value !== null && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value)) + : undefined + return new Response( + [ + 'event: response.created\ndata: {"type":"response.created","response":{"id":"resp_search"}}', + 'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Current answer.\\n\\nSources:\\n\\n- [Example](https://example.com/source)\\n- https://example.org/plain"}', + 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_search","status":"completed"}}', + "", + ].join("\n\n"), + { headers: { "content-type": "text/event-stream" } }, + ) + }) + const result = await Effect.runPromise( + CopilotBridge.search({ + baseURL: root, + model: "gpt-5.6-sol", + query: "current answer", + cacheKey: "search-session", + }), + ) + expect(body).toMatchObject({ + model: "gpt-5.6-sol", + stream: true, + store: false, + prompt_cache_key: "search-session", + tools: [{ type: "web_search_preview" }], + tool_choice: "required", + }) + expect(result.text).toContain("Current answer") + expect(result.citations).toEqual([ + { title: "Example", url: "https://example.com/source" }, + { title: undefined, url: "https://example.org/plain" }, + ]) + expect(result.responseID).toBe("resp_search") + }) +}) diff --git a/packages/core/test/plugin/provider-github-copilot-sdk.test.ts b/packages/core/test/plugin/provider-github-copilot-sdk.test.ts new file mode 100644 index 000000000000..15bb28362ae1 --- /dev/null +++ b/packages/core/test/plugin/provider-github-copilot-sdk.test.ts @@ -0,0 +1,150 @@ +import { afterAll, describe, expect } from "bun:test" +import { rm } from "fs/promises" +import os from "os" +import path from "path" +import { Effect, Layer } from "effect" +import { AISDK } from "@opencode-ai/core/aisdk" +import { Catalog } from "@opencode-ai/core/catalog" +import { CopilotBridge } from "@opencode-ai/core/copilot-bridge" +import { Config } from "@opencode-ai/core/config" +import { ConfigProvider } from "@opencode-ai/core/config/provider" +import { Global } from "@opencode-ai/core/global" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { + GithubCopilotSDKPlugin, + GithubCopilotSDKSecurityPlugin, +} from "@opencode-ai/core/plugin/provider/github-copilot-sdk" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const cacheFile = path.join(os.tmpdir(), `core-copilot-provider-${crypto.randomUUID()}.json`) +const bridge = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + if (new URL(request.url).pathname !== "/v1/models") return new Response("not found", { status: 404 }) + return Response.json({ + object: "list", + data: [ + { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], + capabilities: { + family: "gpt", + limits: { + max_context_window_tokens: 200_000, + max_prompt_tokens: 180_000, + max_output_tokens: 32_000, + }, + supports: { + structured_outputs: true, + tool_calls: true, + vision: true, + }, + }, + }, + ], + }) + }, +}) +const baseURL = `http://127.0.0.1:${bridge.port}/v1` +const config = Layer.succeed( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: new Config.Info({ + providers: { + [CopilotBridge.providerID]: new ConfigProvider.Info({ + api: { + type: "aisdk", + package: "@ai-sdk/openai", + url: baseURL, + settings: { + allowedPorts: [bridge.port], + catalogCacheFile: cacheFile, + }, + }, + }), + }, + }), + }), + ]), + }), +) +const it = testEffect( + Layer.mergeAll( + PluginTestLayer, + config, + Global.layerWith({ + cache: os.tmpdir(), + }), + ), +) + +afterAll(async () => { + await bridge.stop(true) + await rm(cacheFile, { force: true }) +}) + +const host = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + yield* AISDK.Service + return yield* PluginHost.make(plugin) +}) + +describe("GithubCopilotSDKPlugin", () => { + it.effect("is registered as a first-class provider", () => + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("github-copilot-sdk")), + ) + + it.effect("loads bridge models without credentials and locks transport settings", () => + Effect.gen(function* () { + const ctx = yield* host() + yield* GithubCopilotSDKPlugin.effect(ctx) + const catalog = yield* Catalog.Service + const provider = yield* catalog.provider.get(CopilotBridge.providerID) + const model = yield* catalog.model.get(CopilotBridge.providerID, CopilotBridge.defaultModelID) + + expect(provider).toMatchObject({ + id: "github-copilot-sdk", + name: "GitHub Copilot SDK", + api: { + type: "aisdk", + package: "@ai-sdk/openai", + url: baseURL, + settings: { redirect: "error" }, + }, + request: { headers: {}, body: {} }, + }) + expect(model).toMatchObject({ + id: "gpt-5.6-sol", + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + limit: { context: 200_000, input: 180_000, output: 32_000 }, + }) + + yield* catalog.transform((draft) => { + draft.provider.update(CopilotBridge.providerID, (item) => { + item.request.headers.authorization = "Bearer secret" + item.request.body.apiKey = "secret" + }) + draft.model.update(CopilotBridge.providerID, CopilotBridge.defaultModelID, (item) => { + item.request.headers.authorization = "Bearer model-secret" + item.request.body.apiKey = "model-secret" + }) + }) + yield* GithubCopilotSDKSecurityPlugin.effect(ctx) + + expect((yield* catalog.provider.get(CopilotBridge.providerID))?.request).toEqual({ headers: {}, body: {} }) + expect((yield* catalog.model.get(CopilotBridge.providerID, CopilotBridge.defaultModelID))?.request).toEqual({ + headers: {}, + body: {}, + }) + }), + ) +}) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 49bbce95a381..656544b4d04a 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -11,6 +11,7 @@ import { ProjectV2 } from "@opencode-ai/core/project" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionV2 } from "@opencode-ai/core/session" import { AbsolutePath } from "@opencode-ai/core/schema" +import { CopilotBridge } from "@opencode-ai/core/copilot-bridge" import { it } from "./lib/effect" type Api = @@ -61,11 +62,27 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("preserves catalog redirect policy on native Responses routes", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + model({ + type: "aisdk", + package: "@ai-sdk/openai", + url: "http://127.0.0.1:4142/v1", + settings: { redirect: "error" }, + }), + ) + + expect(resolved.route.defaults.http?.redirect).toBe("error") + }), + ) + it.effect("keeps catalog apiKey credentials out of provider JSON", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), ) + const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" })) expect(JSON.stringify(prepared.body)).not.toContain("apiKey") @@ -73,6 +90,35 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("never applies stored credentials to the local Copilot bridge", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + ModelV2.Info.make({ + ...model({ + type: "aisdk", + package: "@ai-sdk/openai", + url: "http://127.0.0.1:4142/v1", + settings: { redirect: "error" }, + }), + providerID: CopilotBridge.providerID, + request: { headers: {}, body: { apiKey: "configured-secret" } }, + }), + Credential.Key.make({ type: "key", key: "stored-secret" }), + ) + const request = LLM.request({ model: resolved, prompt: "Hello" }) + const headers = yield* resolved.route.auth.apply({ + request, + method: "POST", + url: "http://127.0.0.1:4142/v1/responses", + body: "{}", + headers: Headers.empty, + }) + + expect(headers.authorization).toBeUndefined() + expect(resolved.route.defaults.http?.body).toEqual({}) + }), + ) + it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 0515d55cf5be..d0bd6abe9673 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -587,12 +587,16 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context") - expect(contexts).toEqual([ + expect(contexts).toMatchObject([ { sessionID, agent: AgentV2.ID.make("build"), assistantMessageID: expect.stringMatching(/^msg_/), toolCallID: "call-application", + model: { + modelID: "fake-model", + baseURL: "https://api.openai.com/v1", + }, }, ]) expect(yield* session.context(sessionID)).toMatchObject([ diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 0b99a80ecb33..106143444dd8 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -9,6 +9,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WebSearchTool } from "@opencode-ai/core/tool/websearch" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { CopilotBridge } from "@opencode-ai/core/copilot-bridge" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" @@ -45,6 +46,17 @@ describe("WebSearchTool provider selection", () => { test("prefers Exa when only its explicit flag is enabled", () => { expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: false })).toBe("exa") }) + + test("forces Copilot search for Copilot SDK models", () => { + expect( + WebSearchTool.selectProvider( + sessionID, + { enableExa: true, enableParallel: true }, + "exa", + CopilotBridge.providerID, + ), + ).toBe("github-copilot-sdk") + }) }) describe("WebSearchTool MCP response parser", () => { @@ -242,6 +254,82 @@ describe("WebSearchTool registration", () => { }), ) + it.effect("executes Copilot search through the bridge while retaining OpenCode tool settlement", () => + Effect.acquireUseRelease( + Effect.sync(() => { + let request: Record | undefined + let headers: Headers | undefined + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(input) { + headers = input.headers + const value: unknown = await input.json() + request = + typeof value === "object" && value !== null && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value)) + : undefined + return new Response( + [ + 'event: response.created\ndata: {"type":"response.created","response":{"id":"resp_search"}}', + 'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Answer\\n\\nSources:\\n\\n- [Source](https://example.com/source)"}', + 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_search","status":"completed"}}', + "", + ].join("\n\n"), + { headers: { "content-type": "text/event-stream" } }, + ) + }, + }) + return { + server, + request: () => request, + headers: () => headers, + } + }), + (bridge) => + Effect.gen(function* () { + requests.length = 0 + assertions.length = 0 + config = { provider: "exa", enableExa: true, enableParallel: true } + const registry = yield* ToolRegistry.Service + const settled = yield* settleTool(registry, { + sessionID, + ...toolIdentity, + model: { + providerID: CopilotBridge.providerID, + modelID: CopilotBridge.defaultModelID, + baseURL: `http://127.0.0.1:${bridge.server.port}/v1`, + }, + call: { type: "tool-call", id: "call-copilot", name: "websearch", input: { query: "latest" } }, + }) + + expect(bridge.request()).toMatchObject({ + model: "gpt-5.6-sol", + stream: true, + tools: [{ type: "web_search_preview" }], + }) + expect(bridge.headers()?.get("authorization")).toBeNull() + expect(requests).toEqual([]) + expect(assertions[0]?.metadata).toMatchObject({ provider: "github-copilot-sdk" }) + expect(settled).toEqual({ + result: { + type: "text", + value: "Answer\n\nSources:\n\n- [Source](https://example.com/source)", + }, + output: { + structured: { + provider: "github-copilot-sdk", + text: "Answer\n\nSources:\n\n- [Source](https://example.com/source)", + citations: [{ title: "Source", url: "https://example.com/source" }], + }, + content: [{ type: "text", text: "Answer\n\nSources:\n\n- [Source](https://example.com/source)" }], + }, + }) + }), + (bridge) => Effect.sync(() => bridge.server.stop(true)), + ), + ) + it.effect("keeps an Exa credential in the transport URL and out of model output", () => Effect.gen(function* () { requests.length = 0 diff --git a/packages/llm/src/route/transport/http.ts b/packages/llm/src/route/transport/http.ts index acc52c6ea173..8613e74958fe 100644 --- a/packages/llm/src/route/transport/http.ts +++ b/packages/llm/src/route/transport/http.ts @@ -1,5 +1,5 @@ import { Effect, Stream } from "effect" -import { Headers, HttpClientRequest } from "effect/unstable/http" +import { FetchHttpClient, Headers, HttpClientRequest } from "effect/unstable/http" import { Auth } from "../auth" import { render as renderEndpoint } from "../endpoint" import { Framing, type Framing as FramingDef } from "../framing" @@ -129,23 +129,24 @@ export const httpJson = (input: HttpJsonInput): HttpJs ), frames: (prepared, request, runtime) => Stream.unwrap( - runtime.http - .execute(prepared.request) - .pipe( - Effect.map((response) => - prepared.framing.frame( - response.stream.pipe( - Stream.mapError((error) => - ProviderShared.eventError( - `${request.model.provider}/${request.model.route.id}`, - `Failed to read ${request.model.provider}/${request.model.route.id} stream`, - ProviderShared.errorText(error), - ), + runtime.http.execute(prepared.request).pipe( + request.http?.redirect + ? Effect.provideService(FetchHttpClient.RequestInit, { redirect: request.http.redirect }) + : (effect) => effect, + Effect.map((response) => + prepared.framing.frame( + response.stream.pipe( + Stream.mapError((error) => + ProviderShared.eventError( + `${request.model.provider}/${request.model.route.id}`, + `Failed to read ${request.model.provider}/${request.model.route.id} stream`, + ProviderShared.errorText(error), ), ), ), ), ), + ), ), }) diff --git a/packages/llm/src/schema/options.ts b/packages/llm/src/schema/options.ts index 6d11333b536d..e65b1f33694b 100644 --- a/packages/llm/src/schema/options.ts +++ b/packages/llm/src/schema/options.ts @@ -54,6 +54,7 @@ export class HttpOptions extends Schema.Class("LLM.HttpOptions")({ body: Schema.optional(JsonSchema), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), query: Schema.optional(Schema.Record(Schema.String, Schema.String)), + redirect: Schema.optional(Schema.Literals(["follow", "error", "manual"])), }) {} export namespace HttpOptions { @@ -67,8 +68,9 @@ export const mergeHttpOptions = (...items: ReadonlyArray item?.body)) const headers = mergeStringRecords(...items.map((item) => item?.headers)) const query = mergeStringRecords(...items.map((item) => item?.query)) - if (!body && !headers && !query) return undefined - return new HttpOptions({ body, headers, query }) + const redirect = items.findLast((item) => item?.redirect !== undefined)?.redirect + if (!body && !headers && !query && !redirect) return undefined + return new HttpOptions({ body, headers, query, redirect }) } export class GenerationOptions extends Schema.Class("LLM.GenerationOptions")({ diff --git a/packages/llm/test/redirect.test.ts b/packages/llm/test/redirect.test.ts new file mode 100644 index 000000000000..59740a65b57e --- /dev/null +++ b/packages/llm/test/redirect.test.ts @@ -0,0 +1,39 @@ +import { afterAll, expect, test } from "bun:test" +import { Effect, Layer } from "effect" +import { LLM } from "../src" +import { LLMClient, RequestExecutor } from "../src/route" +import { OpenAIResponses } from "../src/protocols/openai-responses" + +let redirected = 0 +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + if (new URL(request.url).pathname === "/collect") { + redirected++ + return new Response("unexpected") + } + return Response.redirect(new URL("/collect", request.url), 302) + }, +}) + +afterAll(() => server.stop(true)) + +test("HTTP routes can reject redirects before forwarding prompt data", async () => { + const model = OpenAIResponses.route + .with({ + provider: "loopback", + endpoint: { baseURL: `http://127.0.0.1:${server.port}/v1` }, + http: { redirect: "error" }, + }) + .model({ id: "test-model" }) + const result = await Effect.runPromise( + LLMClient.generate(LLM.request({ model, prompt: "private prompt" })).pipe( + Effect.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))), + Effect.exit, + ), + ) + + expect(result._tag).toBe("Failure") + expect(redirected).toBe(0) +}) diff --git a/packages/llm/test/route.test.ts b/packages/llm/test/route.test.ts index 681583bc9e47..1f5104f6f878 100644 --- a/packages/llm/test/route.test.ts +++ b/packages/llm/test/route.test.ts @@ -13,12 +13,14 @@ describe("Route.with", () => { query: { keep: "base", base: "1" }, }, headers: { "x-base": "base", "x-override": "base" }, + http: { redirect: "follow" }, auth: Auth.headers({ "x-auth": "old" }), }) .with({ id: "patched-chat", endpoint: { query: { keep: "patch", patch: "1" } }, headers: { "x-override": "patch", "x-patch": "patch" }, + http: { redirect: "error" }, auth, }) @@ -39,5 +41,6 @@ describe("Route.with", () => { "x-override": "patch", "x-patch": "patch", }) + expect(route.defaults.http?.redirect).toBe("error") }) }) diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 38ac4881ccdf..1fdd6faa134e 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -4,6 +4,10 @@ import { ModelsDev } from "@opencode-ai/core/models-dev" import { effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" import { ProviderV2 } from "@opencode-ai/core/provider" +import { CopilotBridge } from "@opencode-ai/core/copilot-bridge" +import { Global } from "@opencode-ai/core/global" +import path from "path" +import { Config } from "@/config/config" export const ModelsCommand = effectCmd({ command: "models [provider]", @@ -27,7 +31,16 @@ export const ModelsCommand = effectCmd({ const { Provider } = yield* Effect.promise(() => import("@/provider/provider")) if (args.refresh) { yield* ModelsDev.Service.use((s) => s.refresh(true)) - UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL) + if (!args.provider || args.provider === CopilotBridge.providerID) { + const config = yield* Config.Service + const options = (yield* config.get()).provider?.[CopilotBridge.providerID]?.options + yield* CopilotBridge.clearCatalog( + typeof options?.catalogCacheFile === "string" + ? options.catalogCacheFile + : path.join(Global.Path.cache, "github-copilot-sdk-models.json"), + ).pipe(Effect.orDie) + } + UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Model caches refreshed" + UI.Style.TEXT_NORMAL) } const provider = yield* Provider.Service diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index a0ab5eb3474d..10b3611176de 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -31,6 +31,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { ModelStatus } from "./model-status" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderError } from "./error" +import { CopilotBridge } from "@opencode-ai/core/copilot-bridge" const OPENAI_HEADER_TIMEOUT_DEFAULT = 300_000 @@ -237,6 +238,48 @@ function custom(dep: CustomDep): Record { }, options: {}, }), + [CopilotBridge.providerID]: Effect.fnUntraced(function* (provider: Info) { + const configuredURL = + typeof provider.options.baseURL === "string" + ? provider.options.baseURL + : ((yield* dep.get("OPENCODE_COPILOT_BRIDGE_URL")) ?? CopilotBridge.defaultBaseURL) + const ports = CopilotBridge.allowedPorts( + provider.options.allowedPorts ?? (yield* dep.get("OPENCODE_COPILOT_BRIDGE_ALLOWED_PORTS")), + ) + const baseURL = CopilotBridge.baseURL(configuredURL, ports) + const fallbackModels = Array.isArray(provider.options.fallbackModels) + ? provider.options.fallbackModels.filter((item): item is string => typeof item === "string") + : [] + return { + autoload: true, + options: { + baseURL, + allowedPorts: ports, + fetch: CopilotBridge.transport(baseURL, ports), + }, + async getModel(sdk: any, modelID: string) { + return sdk.responses(modelID) + }, + discoverModels: () => + Effect.runPromise( + CopilotBridge.catalog({ + baseURL, + allowedPorts: ports, + cacheFile: + typeof provider.options.catalogCacheFile === "string" + ? provider.options.catalogCacheFile + : path.join(Global.Path.cache, "github-copilot-sdk-models.json"), + cacheTTL: + typeof provider.options.catalogCacheTTL === "number" ? provider.options.catalogCacheTTL : undefined, + staleTTL: + typeof provider.options.catalogStaleTTL === "number" ? provider.options.catalogStaleTTL : undefined, + fallbackModels, + }), + ).then((result) => + Object.fromEntries(result.models.map((model) => [model.id, fromCopilotBridgeModel(model, baseURL)])), + ), + } + }), azure: Effect.fnUntraced(function* (provider: Info) { const env = yield* dep.env() const auth = yield* dep.auth(provider.id) @@ -1284,6 +1327,67 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { } } +function fromCopilotBridgeModel(model: CopilotBridge.Model, baseURL: string): Model { + return { + id: model.id, + providerID: CopilotBridge.providerID, + api: { + id: model.id, + url: baseURL, + npm: "@ai-sdk/openai", + }, + name: model.name, + family: model.family, + capabilities: { + temperature: false, + reasoning: model.reasoningEfforts.length > 0, + attachment: model.vision, + toolcall: model.tools, + input: { + text: true, + audio: false, + image: model.vision, + video: false, + pdf: false, + }, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { + context: model.contextLimit, + input: model.inputLimit, + output: model.outputLimit, + }, + options: {}, + headers: {}, + release_date: model.releaseDate, + variants: Object.fromEntries(model.reasoningEfforts.map((effort) => [effort, { reasoningEffort: effort }])), + status: "active", + } +} + +function copilotBridgeProvider(): Info { + return { + id: CopilotBridge.providerID, + name: CopilotBridge.providerName, + source: "custom", + env: [], + options: {}, + models: {}, + } +} + function modeOptions(model: Model, body: Record | undefined) { if (!body) return model.options const options = Object.fromEntries( @@ -1341,6 +1445,7 @@ const layer = Layer.effect( const cfg = yield* config.get() const modelsDev = yield* modelsDevSvc.get() const catalog = mapValues(modelsDev, fromModelsDevProvider) + catalog[CopilotBridge.providerID] = copilotBridgeProvider() const database = mapValues(catalog, toPublicInfo) const providers: Record = {} as Record @@ -1589,18 +1694,23 @@ const layer = Layer.effect( mergeProvider(providerID, partial) } - const gitlab = ProviderV2.ID.make("gitlab") - if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) { - yield* Effect.promise(async () => { - try { - const discovered = await discoveryLoaders[gitlab]() - for (const [modelID, model] of Object.entries(discovered)) { - if (!providers[gitlab].models[modelID]) { - providers[gitlab].models[modelID] = model - } - } - } catch (e) {} - }) + for (const [id, discover] of Object.entries(discoveryLoaders)) { + const providerID = ProviderV2.ID.make(id) + if (!providers[providerID] || !isProviderAllowed(providerID)) continue + const discovered = yield* Effect.tryPromise({ + try: discover, + catch: (cause) => cause, + }).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to discover provider models", { providerID, cause }).pipe( + Effect.as({} as Record), + ), + ), + ) + providers[providerID].models = { + ...discovered, + ...providers[providerID].models, + } } for (const [id, provider] of Object.entries(providers)) { @@ -1611,8 +1721,18 @@ const layer = Layer.effect( } const configProvider = cfg.provider?.[providerID] + if (providerID === CopilotBridge.providerID) { + delete provider.key + provider.env = [] + provider.source = configProvider ? "config" : "custom" + for (const key of ["apiKey", "headers", "organization", "project"]) delete provider.options[key] + } for (const [modelID, model] of Object.entries(provider.models)) { + if (providerID === CopilotBridge.providerID) { + model.headers = {} + for (const key of ["apiKey", "headers", "organization", "project"]) delete model.options[key] + } model.api.id = model.api.id ?? model.id ?? modelID if ( // These chat aliases are invalid for the special handling in the @@ -1719,6 +1839,25 @@ const layer = Layer.effect( ...model.headers, } + if (model.providerID === CopilotBridge.providerID) { + const ports = CopilotBridge.allowedPorts(options["allowedPorts"]) + const url = CopilotBridge.baseURL( + typeof options["baseURL"] === "string" ? options["baseURL"] : CopilotBridge.defaultBaseURL, + ports, + ) + options["baseURL"] = url + options["apiKey"] = "local-bridge" + options["headers"] = {} + options["fetch"] = CopilotBridge.transport(url, ports) + delete options["allowedPorts"] + delete options["catalogCacheTTL"] + delete options["catalogStaleTTL"] + delete options["catalogCacheFile"] + delete options["fallbackModels"] + delete options["organization"] + delete options["project"] + } + const key = Hash.fast( JSON.stringify({ providerID: model.providerID, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 15acc757f3d4..ebe94372afd0 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -54,9 +54,10 @@ import { ModelV2 } from "@opencode-ai/core/model" import { MCP } from "@/mcp" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { McpCatalog } from "@/mcp/catalog" +import { CopilotBridge } from "@opencode-ai/core/copilot-bridge" export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) { - return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel + return providerID === ProviderV2.ID.opencode || providerID === CopilotBridge.providerID || flags.exa || flags.parallel } type TaskDef = Tool.InferDef diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index d08ae1d153e8..1ea448b91039 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -6,6 +6,8 @@ import DESCRIPTION from "./websearch.txt" import { checksum } from "@opencode-ai/core/util/encode" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { RuntimeFlags } from "@/effect/runtime-flags" +import { CopilotBridge } from "@opencode-ai/core/copilot-bridge" +import { ProviderV2 } from "@opencode-ai/core/provider" export const Parameters = Schema.Struct({ query: Schema.String.annotate({ description: "Websearch query" }), @@ -24,12 +26,17 @@ export const Parameters = Schema.Struct({ }), }) -const WebSearchProviderSchema = Schema.Literals(["exa", "parallel"]) +const WebSearchProviderSchema = Schema.Literals(["exa", "parallel", "github-copilot-sdk"]) export type WebSearchProvider = Schema.Schema.Type -export function selectWebSearchProvider(sessionID: string, flags = { exa: false, parallel: false }): WebSearchProvider { +export function selectWebSearchProvider( + sessionID: string, + flags = { exa: false, parallel: false }, + providerID?: ProviderV2.ID, +): WebSearchProvider { + if (providerID === CopilotBridge.providerID) return "github-copilot-sdk" const override = process.env.OPENCODE_WEBSEARCH_PROVIDER - if (override === "exa" || override === "parallel") return override + if (override === "exa" || override === "parallel" || override === "github-copilot-sdk") return override if (flags.parallel) return "parallel" if (flags.exa) return "exa" @@ -37,6 +44,7 @@ export function selectWebSearchProvider(sessionID: string, flags = { exa: false, } export function webSearchProviderLabel(provider: unknown) { + if (provider === CopilotBridge.providerID) return "GitHub Copilot Search" if (provider === "parallel") return "Parallel Web Search" if (provider === "exa") return "Exa Web Search" return "Web Search" @@ -63,6 +71,20 @@ function callProvider( params: Schema.Schema.Type, ctx: Tool.Context, ) { + if (provider === CopilotBridge.providerID) { + const model = extraModel(ctx.extra) + if (!model || model.providerID !== CopilotBridge.providerID) { + return Effect.fail(new Error("GitHub Copilot Search requires the GitHub Copilot SDK model provider")) + } + return CopilotBridge.search({ + baseURL: model.baseURL, + model: model.modelID, + query: params.query, + cacheKey: `websearch:${ctx.sessionID}:${ctx.callID ?? ctx.messageID}`, + signal: ctx.abort, + }).pipe(Effect.map((result) => ({ text: result.text, citations: result.citations }))) + } + if (provider === "parallel") { return McpWebSearch.call( http, @@ -77,7 +99,7 @@ function callProvider( }, "25 seconds", parallelAuthHeaders(), - ) + ).pipe(Effect.map((text) => ({ text: text ?? "", citations: [] }))) } return McpWebSearch.call( @@ -93,7 +115,21 @@ function callProvider( contextMaxCharacters: params.contextMaxCharacters, }, "25 seconds", - ) + ).pipe(Effect.map((text) => ({ text: text ?? "", citations: [] }))) +} + +function extraModel( + extra: Tool.Context["extra"], +): { baseURL: string; modelID: string; providerID: ProviderV2.ID } | undefined { + const model = extra?.model + if (!model || typeof model !== "object") return + const api = "api" in model && model.api && typeof model.api === "object" ? model.api : undefined + const baseURL = api && "url" in api && typeof api.url === "string" ? api.url : undefined + const modelID = api && "id" in api && typeof api.id === "string" ? api.id : undefined + const providerID = + "providerID" in model && typeof model.providerID === "string" ? ProviderV2.ID.make(model.providerID) : undefined + if (!baseURL || !modelID || !providerID) return + return { baseURL, modelID, providerID } } export const WebSearchTool = Tool.define( @@ -109,10 +145,14 @@ export const WebSearchTool = Tool.define( parameters: Parameters, execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { - const provider = selectWebSearchProvider(ctx.sessionID, { - exa: flags.enableExa, - parallel: flags.enableParallel, - }) + const provider = selectWebSearchProvider( + ctx.sessionID, + { + exa: flags.enableExa, + parallel: flags.enableParallel, + }, + extraModel(ctx.extra)?.providerID, + ) const title = webSearchProviderLabel(provider) yield* ctx.metadata({ title: `${title} "${params.query}"`, metadata: { provider } }) @@ -133,9 +173,9 @@ export const WebSearchTool = Tool.define( const result = yield* callProvider(http, provider, params, ctx) return { - output: result ?? "No search results found. Please try a different query.", + output: result.text || "No search results found. Please try a different query.", title: `${title}: ${params.query}`, - metadata: { provider }, + metadata: { provider, citations: result.citations }, } }).pipe(Effect.orDie), } diff --git a/packages/opencode/test/provider/github-copilot-sdk.test.ts b/packages/opencode/test/provider/github-copilot-sdk.test.ts new file mode 100644 index 000000000000..e788ec3087a6 --- /dev/null +++ b/packages/opencode/test/provider/github-copilot-sdk.test.ts @@ -0,0 +1,114 @@ +import { afterAll, describe, expect } from "bun:test" +import path from "path" +import os from "os" +import { rm } from "fs/promises" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { CopilotBridge } from "@opencode-ai/core/copilot-bridge" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Provider } from "@/provider/provider" +import { testEffect } from "../lib/effect" +import { isOverflow, usable } from "@/session/overflow" + +const requests: Request[] = [] +const bridge = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + requests.push(request) + if (new URL(request.url).pathname === "/v1/models") { + return Response.json({ + object: "list", + data: [ + { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], + defaultReasoningEffort: "medium", + policy: { state: "enabled" }, + capabilities: { + family: "gpt", + limits: { + max_context_window_tokens: 200_000, + max_prompt_tokens: 180_000, + max_output_tokens: 32_000, + }, + supports: { + streaming: true, + structured_outputs: true, + tool_calls: true, + vision: true, + }, + }, + }, + ], + }) + } + return new Response("not found", { status: 404 }) + }, +}) +const baseURL = `http://127.0.0.1:${bridge.port}/v1` +const cacheFile = path.join(os.tmpdir(), `opencode-copilot-provider-${crypto.randomUUID()}.json`) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Provider.node]))) + +afterAll(async () => { + await bridge.stop(true) + await rm(cacheFile, { force: true }) +}) + +describe("github-copilot-sdk provider", () => { + it.instance( + "discovers models without credentials and uses the Responses adapter", + Effect.gen(function* () { + const providers = yield* Provider.use.list() + const provider = providers[CopilotBridge.providerID] + expect(provider).toBeDefined() + expect(provider.source).toBe("config") + expect(provider.key).toBeUndefined() + expect(provider.env).toEqual([]) + expect(provider.options.baseURL).toBe(baseURL) + expect(provider.options.apiKey).toBeUndefined() + expect(provider.options.headers).toBeUndefined() + + const model = provider.models[CopilotBridge.defaultModelID] + expect(model).toMatchObject({ + id: "gpt-5.6-sol", + providerID: "github-copilot-sdk", + api: { id: "gpt-5.6-sol", npm: "@ai-sdk/openai", url: baseURL }, + capabilities: { reasoning: true, attachment: true, toolcall: true }, + limit: { context: 200_000, input: 180_000, output: 32_000 }, + }) + expect(Object.keys(model.variants ?? {})).toEqual(["low", "medium", "high", "xhigh"]) + expect(usable({ cfg: {}, model })).toBe(160_000) + expect( + isOverflow({ + cfg: {}, + model, + tokens: { total: 160_000, input: 160_000, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }), + ).toBe(true) + + const language = yield* Provider.use.getLanguage(model) + expect(language.provider).toContain("github-copilot-sdk") + expect(requests).toHaveLength(1) + expect(requests[0]?.headers.get("authorization")).toBeNull() + }), + { + config: { + model: `${CopilotBridge.providerID}/${CopilotBridge.defaultModelID}`, + provider: { + [CopilotBridge.providerID]: { + options: { + baseURL, + allowedPorts: [bridge.port], + apiKey: "must-not-leak", + headers: { authorization: "Bearer must-not-leak" }, + catalogCacheFile: cacheFile, + }, + }, + }, + }, + }, + 60_000, + ) +}) diff --git a/packages/opencode/test/session/copilot-sdk-integration.test.ts b/packages/opencode/test/session/copilot-sdk-integration.test.ts new file mode 100644 index 000000000000..12a3661fd555 --- /dev/null +++ b/packages/opencode/test/session/copilot-sdk-integration.test.ts @@ -0,0 +1,472 @@ +import { afterAll, beforeEach, describe, expect } from "bun:test" +import { rm } from "fs/promises" +import os from "os" +import path from "path" +import { tool, type ModelMessage } from "ai" +import { Effect, Fiber, Scope, Stream } from "effect" +import z from "zod" +import type { LLMEvent } from "@opencode-ai/llm" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { CopilotBridge } from "@opencode-ai/core/copilot-bridge" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import type { Agent } from "@/agent/agent" +import { Provider } from "@/provider/provider" +import { LLM } from "@/session/llm" +import { MessageID, SessionID } from "@/session/schema" +import { testEffect } from "../lib/effect" + +type Capture = { + readonly body: Record + readonly headers: Headers + readonly signal: AbortSignal +} + +const captures: Capture[] = [] +const responses: Array<(request: Request) => Response | Promise> = [] +const cacheFile = path.join(os.tmpdir(), `opencode-copilot-llm-${crypto.randomUUID()}.json`) +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const url = new URL(request.url) + if (request.method === "GET" && url.pathname === "/v1/models") { + return Response.json({ + object: "list", + data: [ + { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], + defaultReasoningEffort: "medium", + policy: { state: "enabled" }, + capabilities: { + family: "gpt", + limits: { + max_context_window_tokens: 200_000, + max_prompt_tokens: 180_000, + max_output_tokens: 32_000, + }, + supports: { + streaming: true, + structured_outputs: true, + tool_calls: true, + vision: true, + }, + }, + }, + ], + }) + } + if (request.method !== "POST" || url.pathname !== "/v1/responses") { + return new Response("not found", { status: 404 }) + } + const value: unknown = await request.json() + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return new Response("invalid request", { status: 400 }) + } + captures.push({ + body: Object.fromEntries(Object.entries(value)), + headers: request.headers, + signal: request.signal, + }) + const response = responses.shift() + return response ? response(request) : new Response("unexpected request", { status: 500 }) + }, +}) +const baseURL = `http://127.0.0.1:${server.port}/v1` +const it = testEffect(AppNodeBuilder.build(LayerNode.group([LLM.node, Provider.node]))) + +beforeEach(() => { + captures.length = 0 + responses.length = 0 +}) + +afterAll(async () => { + await server.stop(true) + await rm(cacheFile, { force: true }) +}) + +function config() { + return { + enabled_providers: [CopilotBridge.providerID], + provider: { + [CopilotBridge.providerID]: { + options: { + baseURL, + allowedPorts: [server.port], + catalogCacheFile: cacheFile, + catalogCacheTTL: 0, + }, + }, + }, + } +} + +function events(chunks: readonly Record[]) { + return new Response( + `${chunks + .map( + (chunk) => `event: ${typeof chunk.type === "string" ? chunk.type : "message"}\ndata: ${JSON.stringify(chunk)}`, + ) + .join("\n\n")}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ) +} + +function completed(model = "gpt-5.6-sol", usage = { input: 8, output: 3, reasoning: 1 }) { + return { + type: "response.completed", + sequence_number: 20, + response: { + id: "resp_test", + object: "response", + status: "completed", + model, + output: [], + incomplete_details: null, + service_tier: null, + usage: { + input_tokens: usage.input, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: usage.output, + output_tokens_details: { reasoning_tokens: usage.reasoning }, + }, + }, + } +} + +function textResponse(text: string) { + return events([ + { + type: "response.created", + sequence_number: 1, + response: { id: "resp_test", status: "in_progress", model: "gpt-5.6-sol" }, + }, + { + type: "response.output_item.added", + sequence_number: 2, + output_index: 0, + item: { id: "msg_test", type: "message", status: "in_progress", role: "assistant", content: [] }, + }, + { + type: "response.content_part.added", + sequence_number: 3, + item_id: "msg_test", + output_index: 0, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }, + { + type: "response.output_text.delta", + sequence_number: 4, + item_id: "msg_test", + output_index: 0, + content_index: 0, + delta: text, + logprobs: null, + }, + { + type: "response.output_item.done", + sequence_number: 5, + output_index: 0, + item: { + id: "msg_test", + type: "message", + status: "completed", + role: "assistant", + content: [{ type: "output_text", text, annotations: [] }], + }, + }, + completed(), + ]) +} + +function toolResponse(name: string, input: unknown) { + const args = JSON.stringify(input) + return events([ + { + type: "response.created", + sequence_number: 1, + response: { id: "resp_tool", status: "in_progress", model: "gpt-5.6-sol" }, + }, + { + type: "response.output_item.done", + sequence_number: 2, + output_index: 0, + item: { + id: "fc_test", + type: "function_call", + status: "completed", + call_id: "call_test", + name, + arguments: args, + }, + }, + completed("gpt-5.6-sol", { input: 10, output: 2, reasoning: 0 }), + ]) +} + +function timeout(value: Promise, milliseconds = 2_000) { + return Promise.race([ + value, + new Promise((_, reject) => + setTimeout(() => reject(new Error("timed out waiting for cancellation")), milliseconds), + ), + ]) +} + +function input( + model: Provider.Model, + options: { + readonly messages?: ModelMessage[] + readonly tools?: LLM.StreamInput["tools"] + readonly variant?: string + readonly retries?: number + } = {}, +): LLM.StreamInput { + const sessionID = SessionID.make("ses_copilot_sdk_test") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + return { + user: { + id: MessageID.make("msg_copilot_sdk_user"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { + providerID: CopilotBridge.providerID, + modelID: model.id, + variant: options.variant, + }, + } satisfies SessionV1.User, + sessionID, + model, + agent, + system: ["You are an OpenCode test assistant."], + messages: options.messages ?? [{ role: "user", content: "Reply exactly." }], + tools: options.tools ?? {}, + retries: options.retries, + } +} + +const collect = (value: LLM.StreamInput): Effect.Effect => + LLM.Service.use((service) => + service.stream(value).pipe( + Stream.runCollect, + Effect.map((output) => Array.from(output)), + ), + ) + +describe("GitHub Copilot SDK Responses integration", () => { + it.instance( + "streams text and usage without sending credentials", + Effect.gen(function* () { + responses.push(() => textResponse("OPENCODE_BRIDGE_OK")) + const model = yield* Provider.use.getModel(CopilotBridge.providerID, CopilotBridge.defaultModelID) + const output = yield* collect(input(model, { variant: "high" })) + const capture = captures[0] + + expect( + output + .filter((event) => event.type === "text-delta") + .map((event) => event.text) + .join(""), + ).toBe("OPENCODE_BRIDGE_OK") + expect(output.find((event) => event.type === "step-finish")).toMatchObject({ + usage: { inputTokens: 8, outputTokens: 3, reasoningTokens: 1 }, + }) + expect(capture?.body).toMatchObject({ + model: "gpt-5.6-sol", + stream: true, + store: false, + prompt_cache_key: "ses_copilot_sdk_test", + reasoning: { effort: "high" }, + }) + expect(capture?.body.input).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "message", role: "user" })]), + ) + expect(capture?.headers.get("authorization")).toBeNull() + expect(capture?.headers.get("x-api-key")).toBeNull() + }), + { config }, + 60_000, + ) + + it.instance( + "executes model-requested structured output tools inside OpenCode", + Effect.gen(function* () { + responses.push(() => toolResponse("StructuredOutput", { answer: 4 })) + let executions = 0 + const model = yield* Provider.use.getModel(CopilotBridge.providerID, CopilotBridge.defaultModelID) + const output = yield* collect( + input(model, { + tools: { + StructuredOutput: tool({ + description: "Return the final structured answer", + inputSchema: z.object({ answer: z.number() }), + execute: async ({ answer }) => { + executions++ + return { output: { answer }, title: "Structured Output", metadata: { owner: "opencode" } } + }, + }), + }, + }), + ) + + expect(executions).toBe(1) + const call = output.find((event) => event.type === "tool-call") + expect(call).toMatchObject({ + id: "call_test", + name: "StructuredOutput", + input: { answer: 4 }, + }) + expect(call?.type === "tool-call" ? call.providerExecuted : undefined).not.toBe(true) + expect(output.find((event) => event.type === "tool-result")).toMatchObject({ + id: "call_test", + name: "StructuredOutput", + result: { + type: "json", + value: { output: { answer: 4 }, title: "Structured Output", metadata: { owner: "opencode" } }, + }, + }) + expect(captures[0]?.body.tools).toEqual([ + expect.objectContaining({ type: "function", name: "StructuredOutput", strict: false }), + ]) + }), + { config }, + 60_000, + ) + + it.instance( + "projects image input and streamed reasoning metadata", + Effect.gen(function* () { + responses.push(() => + events([ + { + type: "response.created", + sequence_number: 1, + response: { id: "resp_reason", status: "in_progress", model: "gpt-5.6-sol" }, + }, + { + type: "response.output_item.added", + sequence_number: 2, + output_index: 0, + item: { id: "rs_test", type: "reasoning", encrypted_content: null }, + }, + { + type: "response.reasoning_summary_part.added", + sequence_number: 3, + item_id: "rs_test", + summary_index: 0, + }, + { + type: "response.reasoning_summary_text.delta", + sequence_number: 4, + item_id: "rs_test", + summary_index: 0, + delta: "Checked the image", + }, + { + type: "response.output_item.done", + sequence_number: 5, + output_index: 0, + item: { id: "rs_test", type: "reasoning", encrypted_content: null }, + }, + completed(), + ]), + ) + const model = yield* Provider.use.getModel(CopilotBridge.providerID, CopilotBridge.defaultModelID) + const output = yield* collect( + input(model, { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Inspect the image" }, + { type: "image", image: "data:image/png;base64,YWJj", mediaType: "image/png" }, + ], + }, + ], + }), + ) + const request = JSON.stringify(captures[0]?.body) + + expect(request).toContain('"type":"input_image"') + expect(request).toContain("data:image/png;base64,YWJj") + expect(output.find((event) => event.type === "reasoning-delta")).toMatchObject({ + text: "Checked the image", + providerMetadata: { openai: { itemId: "rs_test" } }, + }) + }), + { config }, + 60_000, + ) + + it.instance( + "cancels the in-flight bridge stream when OpenCode interrupts the turn", + Effect.gen(function* () { + let requestedResolve: (() => void) | undefined + let stoppedResolve: (() => void) | undefined + const requested = new Promise((resolve) => (requestedResolve = resolve)) + const stopped = new Promise((resolve) => (stoppedResolve = resolve)) + responses.push((request) => { + requestedResolve?.() + request.signal.addEventListener("abort", () => stoppedResolve?.(), { once: true }) + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'event: response.created\ndata: {"type":"response.created","response":{"id":"resp_hang","status":"in_progress","model":"gpt-5.6-sol"}}\n\n', + ), + ) + }, + cancel() { + stoppedResolve?.() + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + }) + const model = yield* Provider.use.getModel(CopilotBridge.providerID, CopilotBridge.defaultModelID) + const scope = yield* Scope.Scope + const fiber = yield* collect(input(model)).pipe(Effect.forkIn(scope)) + + yield* Effect.promise(() => requested) + yield* Fiber.interrupt(fiber) + yield* Effect.promise(() => timeout(stopped)) + expect(captures).toHaveLength(1) + }), + { config }, + 60_000, + ) + + it.instance( + "retries transient bridge errors without changing providers", + Effect.gen(function* () { + responses.push( + () => Response.json({ error: { message: "temporary", type: "copilot_bridge_error" } }, { status: 503 }), + () => textResponse("recovered"), + ) + const model = yield* Provider.use.getModel(CopilotBridge.providerID, CopilotBridge.defaultModelID) + const output = yield* collect(input(model, { retries: 1 })) + + expect(captures).toHaveLength(2) + expect( + output + .filter((event) => event.type === "text-delta") + .map((event) => event.text) + .join(""), + ).toBe("recovered") + expect(captures.every((capture) => capture.headers.get("authorization") === null)).toBe(true) + }), + { config }, + 60_000, + ) +}) diff --git a/packages/opencode/test/tool/websearch.test.ts b/packages/opencode/test/tool/websearch.test.ts index 349606dec735..1733ef2dd0b0 100644 --- a/packages/opencode/test/tool/websearch.test.ts +++ b/packages/opencode/test/tool/websearch.test.ts @@ -6,6 +6,7 @@ import { selectWebSearchProvider, webSearchModelName, webSearchProviderLabel } f import { webSearchEnabled } from "../../src/tool/registry" import { it } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" +import { CopilotBridge } from "@opencode-ai/core/copilot-bridge" const SESSION_ID = "ses_0196aabbccddeeff001122334455" @@ -37,8 +38,15 @@ describe("websearch provider", () => { expect(selectWebSearchProvider(SESSION_ID, { exa: false, parallel: true })).toBe("parallel") }) - test("is only enabled for opencode or explicit websearch provider flags", () => { + test("always routes Copilot SDK models through Copilot search", () => { + expect(selectWebSearchProvider(SESSION_ID, { exa: true, parallel: true }, CopilotBridge.providerID)).toBe( + "github-copilot-sdk", + ) + }) + + test("is enabled for opencode, Copilot SDK, or explicit websearch provider flags", () => { expect(webSearchEnabled(ProviderV2.ID.opencode, { exa: false, parallel: false })).toBe(true) + expect(webSearchEnabled(CopilotBridge.providerID, { exa: false, parallel: false })).toBe(true) expect(webSearchEnabled(ProviderV2.ID.openai, { exa: false, parallel: false })).toBe(false) expect(webSearchEnabled(ProviderV2.ID.openai, { exa: true, parallel: false })).toBe(true) expect(webSearchEnabled(ProviderV2.ID.openai, { exa: false, parallel: true })).toBe(true) @@ -47,6 +55,7 @@ describe("websearch provider", () => { test("uses branded labels", () => { expect(webSearchProviderLabel("parallel")).toBe("Parallel Web Search") expect(webSearchProviderLabel("exa")).toBe("Exa Web Search") + expect(webSearchProviderLabel(CopilotBridge.providerID)).toBe("GitHub Copilot Search") expect(webSearchProviderLabel(undefined)).toBe("Web Search") }) diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index 51ff4b37913d..9c68215cc65f 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -14,6 +14,7 @@ export const ID = Schema.String.pipe( google: schema.make("google"), googleVertex: schema.make("google-vertex"), githubCopilot: schema.make("github-copilot"), + githubCopilotSDK: schema.make("github-copilot-sdk"), amazonBedrock: schema.make("amazon-bedrock"), azure: schema.make("azure"), openrouter: schema.make("openrouter"), diff --git a/packages/ui/src/components/provider-icon.tsx b/packages/ui/src/components/provider-icon.tsx index 7c0eb3d047df..a8ab0ce55163 100644 --- a/packages/ui/src/components/provider-icon.tsx +++ b/packages/ui/src/components/provider-icon.tsx @@ -1,7 +1,9 @@ import type { Component, JSX } from "solid-js" import { createMemo, splitProps } from "solid-js" import sprite from "./provider-icons/sprite.svg" -import { iconNames, type IconName } from "./provider-icons/types" +import { iconNames } from "./provider-icons/types" + +const names = new Set(iconNames) export type ProviderIconProps = JSX.SVGElementTags["svg"] & { id: string @@ -9,7 +11,10 @@ export type ProviderIconProps = JSX.SVGElementTags["svg"] & { export const ProviderIcon: Component = (props) => { const [local, rest] = splitProps(props, ["id", "class", "classList"]) - const resolved = createMemo(() => (iconNames.includes(local.id as IconName) ? local.id : "synthetic")) + const resolved = createMemo(() => { + if (local.id.startsWith("github-copilot")) return "github-copilot" + return names.has(local.id) ? local.id : "synthetic" + }) return ( Date: Mon, 20 Jul 2026 02:12:43 -0700 Subject: [PATCH 2/9] docs: document local Copilot bridge Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 660b95b9-9b53-4378-bdc5-efef35aa73a1 --- packages/web/src/content/docs/providers.mdx | 172 ++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index d3c541311ffe..069c5ab6e888 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1106,6 +1106,178 @@ subscription](https://github.com/features/copilot/plans) to use. --- +### GitHub Copilot SDK (local bridge) + +`github-copilot-sdk` is a separate, local-only provider for a +Responses-compatible bridge backed by the supported GitHub Copilot SDK. It does +not use the direct GitHub Copilot API provider above. + +:::important +OpenCode remains the harness. OpenCode owns sessions, agents, prompts, tools, +permissions, MCP, plugins, retries, compaction, persistence, CLI, server, +desktop, and UI. The local bridge supplies model inference and Copilot-hosted +web search only. A bridge must never execute OpenCode shell, filesystem, edit, +MCP, or other local tools. +::: + +#### Start the bridge + +Start a compatible bridge before OpenCode. The bridge owns GitHub/Copilot +authentication and must bind to a literal loopback address. OpenCode does not +store or send a GitHub token for this provider. + +The default fork endpoint is: + +```txt +http://127.0.0.1:4142/v1 +``` + +If the bridge uses another port, set the URL in `opencode.json`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "model": "github-copilot-sdk/gpt-5.6-sol", + "provider": { + "github-copilot-sdk": { + "options": { + "baseURL": "http://127.0.0.1:4141/v1", + "allowedPorts": [4141] + } + } + } +} +``` + +Or set: + +```sh +export OPENCODE_COPILOT_BRIDGE_URL=http://127.0.0.1:4141/v1 +export OPENCODE_COPILOT_BRIDGE_ALLOWED_PORTS=4141 +``` + +The URL must use `http`, a literal loopback IP (`127.0.0.0/8` or `::1`), and the +exact `/v1` path. Userinfo, DNS hostnames such as `localhost`, query strings, +fragments, redirects, and ports outside `allowedPorts` are rejected. OpenCode +strips authorization, cookie, API-key, organization, project, and custom +headers before every bridge request. + +#### Bridge contract + +OpenCode requires these endpoints: + +| Method | Endpoint | Purpose | +| ------ | --------------- | ------------------------------------ | +| `GET` | `/v1/models` | Discover the signed-in user's models | +| `POST` | `/v1/responses` | Stream inference and web search | + +Inference requests use `stream: true`, `store: false`, a stable +`prompt_cache_key`, standard Responses `input`, and declaration-only function +tools. The bridge returns function calls to OpenCode; OpenCode authorizes and +executes them, then sends the tool result on the next provider turn. + +For web search, the OpenCode `websearch` tool makes a separate search-only +Responses request with: + +```json +{ + "tools": [{ "type": "web_search_preview" }], + "tool_choice": "required", + "stream": true, + "store": false +} +``` + +Only Copilot-hosted web search may run in that request. Citation URLs are kept +in the tool output and structured tool metadata so the existing OpenCode UI can +render and open sources. + +`/v1/models` may return the standard minimal model list. For accurate limits, +vision support, structured-output support, and reasoning variants, each model +should also expose the corresponding Copilot SDK catalog fields: + +```json +{ + "id": "gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "policy": { "state": "enabled" }, + "supportedReasoningEfforts": ["low", "medium", "high", "xhigh"], + "defaultReasoningEffort": "medium", + "capabilities": { + "family": "gpt", + "limits": { + "max_context_window_tokens": 200000, + "max_prompt_tokens": 180000, + "max_output_tokens": 32000 + }, + "supports": { + "streaming": true, + "structured_outputs": true, + "tool_calls": true, + "vision": true + } + } +} +``` + +OpenCode caches a successful catalog for five minutes and may reuse it for up +to seven days if refresh fails. Refresh both model caches with: + +```sh +opencode models github-copilot-sdk --refresh +``` + +An offline model fallback is disabled by default. Pin one only when accepting +that the picker can be stale: + +```json title="opencode.json" +{ + "provider": { + "github-copilot-sdk": { + "options": { + "fallbackModels": ["gpt-5.6-sol"] + } + } + } +} +``` + +The bridge still must be running when inference starts. There is no fallback to +OpenCode Cloud, OpenAI, Anthropic, the direct GitHub Copilot provider, or any +other provider. + +#### Capability notes + +- Reasoning effort is forwarded when the selected model advertises it. +- Images use Responses `input_image` data URLs. +- Structured output remains an OpenCode function tool and is executed by + OpenCode. +- Cancellation aborts the bridge HTTP stream. +- Retry and context-overflow handling remain OpenCode behavior. +- Exact token usage, reasoning summaries, and service tiers are shown only when + the bridge emits the standard Responses metadata. They are never synthesized + as authoritative values. +- A bridge may keep transport-internal SDK routing state, but OpenCode's durable + transcript is the source of truth and the bridge must not become a second + agent loop. + +#### Troubleshooting + +- **Provider missing from the model picker**: start the bridge, verify + `GET /v1/models`, then run the refresh command above. +- **Bridge unavailable**: OpenCode reports the provider error and does not + silently switch providers. +- **Invalid URL**: use a literal loopback address and `/v1`; update + `allowedPorts` when intentionally changing the port. +- **No citations**: verify the bridge emits `web_search_call` events and either + standard `url_citation` annotations or source links in the final text. +- **No usage or reasoning metadata**: the bridge must include those standard + Responses fields; OpenCode cannot derive exact provider values. +- **Authentication prompt appears**: select `github-copilot-sdk`, not + `github-copilot`. Authentication belongs to the bridge process. + +--- + ### GMI Cloud To use GMI Cloud with OpenCode: From ead3d8043a224413f1b151f6044060dd45c9a2fa Mon Sep 17 00:00:00 2001 From: Costas Panay Date: Mon, 20 Jul 2026 10:10:29 -0700 Subject: [PATCH 3/9] fix(core): harden copilot bridge provider Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fc19188-72f7-45c9-9cb3-fc3aad38a17c --- packages/core/src/copilot-bridge.ts | 499 ++++++++++-------- packages/core/src/plugin/internal.ts | 3 + packages/core/src/plugin/models-dev.ts | 2 + packages/core/src/plugin/provider.ts | 2 - .../src/plugin/provider/github-copilot-sdk.ts | 225 +++----- packages/core/src/session/runner/llm.ts | 4 +- packages/core/src/session/runner/model.ts | 65 ++- packages/core/src/tool/registry.ts | 9 +- packages/core/src/tool/tool.ts | 2 + packages/core/src/tool/websearch.ts | 254 +-------- packages/core/test/copilot-bridge.test.ts | 83 ++- packages/core/test/location-layer.test.ts | 2 - .../provider-github-copilot-sdk.test.ts | 145 ++++- .../core/test/session-runner-model.test.ts | 39 +- packages/core/test/tool-websearch.test.ts | 448 ++++------------ .../llm/src/protocols/openai-responses.ts | 39 +- packages/opencode/src/cli/cmd/models.ts | 11 +- packages/opencode/src/effect/runtime-flags.ts | 9 - packages/opencode/src/provider/provider.ts | 107 ++-- packages/opencode/src/session/llm.ts | 12 +- packages/opencode/src/tool/mcp-websearch.ts | 96 ---- packages/opencode/src/tool/registry.ts | 6 +- packages/opencode/src/tool/websearch.ts | 179 +------ .../test/effect/runtime-flags.test.ts | 6 - .../test/provider/github-copilot-sdk.test.ts | 105 +++- .../session/copilot-sdk-integration.test.ts | 50 +- packages/opencode/test/tool/websearch.test.ts | 106 +--- packages/web/src/content/docs/cli.mdx | 59 ++- packages/web/src/content/docs/providers.mdx | 70 +-- packages/web/src/content/docs/tools.mdx | 14 +- 30 files changed, 1103 insertions(+), 1548 deletions(-) delete mode 100644 packages/opencode/src/tool/mcp-websearch.ts diff --git a/packages/core/src/copilot-bridge.ts b/packages/core/src/copilot-bridge.ts index 2b1ff9396152..68d0b5f6bfea 100644 --- a/packages/core/src/copilot-bridge.ts +++ b/packages/core/src/copilot-bridge.ts @@ -1,14 +1,19 @@ export * as CopilotBridge from "./copilot-bridge" -import { mkdir, readFile, rename, rm, writeFile } from "fs/promises" +import { constants } from "fs" +import { createHmac, randomBytes, timingSafeEqual } from "crypto" +import { lstat, mkdir, open, readFile, rename, rm, writeFile } from "fs/promises" import path from "path" -import { Cause, Effect, Exit, Option, Schema } from "effect" +import { Cause, Context, Effect, Exit, Layer, Option, Schema } from "effect" import { ModelV2 } from "./model" import { ProviderV2 } from "./provider" +import { makeGlobalNode } from "./effect/app-node" +import { Global } from "./global" export const providerID = ProviderV2.ID.githubCopilotSDK export const providerName = "GitHub Copilot SDK" -export const defaultBaseURL = "http://127.0.0.1:4142/v1" +export const placeholderBaseURL = "http://127.0.0.1:1/v1" +export const stateDirectoryEnvironment = "OPENCODE_COPILOT_BRIDGE_STATE_DIR" export const defaultModelID = ModelV2.ID.make("gpt-5.6-sol") export const defaultCatalogTTL = 5 * 60 * 1000 export const defaultStaleTTL = 7 * 24 * 60 * 60 * 1000 @@ -29,15 +34,26 @@ export class RedirectError extends Schema.TaggedErrorClass()("Cop } } +export class AuthenticationError extends Schema.TaggedErrorClass()( + "CopilotBridge.Authentication", + { + message: Schema.String, + }, +) {} + +export class ConnectionError extends Schema.TaggedErrorClass()("CopilotBridge.Connection", { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + export class CatalogUnavailableError extends Schema.TaggedErrorClass()( "CopilotBridge.CatalogUnavailable", { - baseURL: Schema.String, cause: Schema.optional(Schema.Defect()), }, ) { override get message() { - return `GitHub Copilot SDK model catalog is unavailable at ${this.baseURL}/models` + return "GitHub Copilot SDK model catalog is unavailable" } } @@ -77,8 +93,7 @@ const Model = Schema.Struct({ }) const Cache = Schema.Struct({ - version: Schema.Literal(1), - baseURL: Schema.String, + version: Schema.Literal(2), fetchedAt: Schema.Number, models: Schema.Array(Model), }) @@ -90,17 +105,13 @@ const List = Schema.Struct({ export interface CatalogResult { readonly models: readonly Model[] - readonly source: "remote" | "fresh-cache" | "stale-cache" | "fallback" + readonly source: "remote" | "fresh-cache" | "stale-cache" readonly fetchedAt: number } export interface CatalogInput { - readonly baseURL?: string - readonly allowedPorts?: readonly number[] + readonly connect: () => Promise readonly cacheFile: string - readonly cacheTTL?: number - readonly staleTTL?: number - readonly fallbackModels?: readonly string[] readonly refresh?: boolean readonly defaults?: { readonly contextLimit?: number @@ -111,29 +122,9 @@ export interface CatalogInput { } } -export interface SearchInput { - readonly baseURL?: string - readonly allowedPorts?: readonly number[] - readonly model: string - readonly query: string - readonly cacheKey?: string - readonly signal?: AbortSignal -} - -export interface Citation { - readonly url: string - readonly title?: string -} - -export interface SearchResult { - readonly text: string - readonly citations: readonly Citation[] - readonly responseID?: string -} - export type Fetch = (request: string | URL | Request, init?: RequestInit) => Promise -export function baseURL(input = defaultBaseURL, allowedPorts?: readonly number[]) { +export function baseURL(input: string) { if (!URL.canParse(input)) throw new InvalidURLError({ url: input, message: "URL is not valid" }) const url = new URL(input) if (url.protocol !== "http:") { @@ -145,8 +136,8 @@ export function baseURL(input = defaultBaseURL, allowedPorts?: readonly number[] if (url.search || url.hash) { throw new InvalidURLError({ url: input, message: "URL query strings and fragments are not allowed" }) } - if (!loopback(url.hostname)) { - throw new InvalidURLError({ url: input, message: "Host must be a literal loopback address" }) + if (url.hostname !== "127.0.0.1") { + throw new InvalidURLError({ url: input, message: "Host must be the literal bridge address 127.0.0.1" }) } if (url.pathname.replace(/\/+$/, "") !== "/v1") { throw new InvalidURLError({ url: input, message: "Base URL path must be /v1" }) @@ -155,57 +146,228 @@ export function baseURL(input = defaultBaseURL, allowedPorts?: readonly number[] if (!Number.isInteger(port) || port < 1 || port > 65_535) { throw new InvalidURLError({ url: input, message: "Port must be between 1 and 65535" }) } - if (allowedPorts?.length && !allowedPorts.includes(port)) { - throw new InvalidURLError({ url: input, message: `Port ${port} is not allowed by provider policy` }) - } return `${url.origin}/v1` } -export function allowedPorts(input: unknown) { - const values = Array.isArray(input) - ? input - : typeof input === "string" - ? input - .split(",") - .map((item) => Number(item.trim())) - .filter((item) => Number.isFinite(item)) - : [] - if (!values.length) return undefined - const result = [ - ...new Set(values.filter((item): item is number => Number.isInteger(item) && item > 0 && item <= 65_535)), - ] - return result.length ? result : undefined +const Descriptor = Schema.Struct({ + version: Schema.Literal(1), + base_url: Schema.String, + instance_id: Schema.String, + pid: Schema.Number, + capability_file: Schema.String, + model_catalog_json: Schema.String, + paste_directory: Schema.Union([Schema.String, Schema.Null]), +}) + +const Challenge = Schema.Struct({ + instance_id: Schema.String, + proof: Schema.String, +}) + +export interface Connection { + readonly baseURL: string + readonly instanceID: string + readonly pid: number + readonly capability: string +} + +function capability(input: string) { + if (!/^[A-Za-z0-9_-]+$/.test(input)) { + throw new AuthenticationError({ message: "Copilot bridge capability must be base64url text" }) + } + const bytes = Buffer.from(input, "base64url") + if (bytes.length < 32 || bytes.toString("base64url") !== input) { + throw new AuthenticationError({ message: "Copilot bridge capability must contain at least 256 random bits" }) + } + return input +} + +function inside(parent: string, child: string) { + const relative = path.relative(parent, child) + return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) +} + +async function rejectSymlinks(destination: string) { + const parsed = path.parse(destination) + const components = destination.slice(parsed.root.length).split(path.sep).filter(Boolean) + let current = parsed.root + for (const component of components) { + current = path.join(current, component) + if ((await lstat(current)).isSymbolicLink()) throw new Error("Copilot bridge paths must not contain symlinks") + } +} + +function assertOwned(uid: number, label: string) { + const current = typeof process.getuid === "function" ? process.getuid() : undefined + if (current !== undefined && Number.isInteger(uid) && uid !== current) { + throw new Error(`${label} must be owned by the current user`) + } +} + +async function privateFile(stateDirectory: string, destination: string, label: string, maxBytes: number) { + const resolved = path.resolve(destination) + if (!inside(stateDirectory, resolved)) throw new Error(`${label} must be inside the bridge state directory`) + await rejectSymlinks(resolved) + const handle = await open(resolved, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + try { + const stats = await handle.stat() + if (!stats.isFile() || stats.nlink !== 1) throw new Error(`${label} must be a private regular file`) + assertOwned(stats.uid, label) + if (process.platform !== "win32" && (stats.mode & 0o777) !== 0o600) { + throw new Error(`${label} must have mode 0600`) + } + if (stats.size > maxBytes) throw new Error(`${label} is too large`) + return await handle.readFile("utf8") + } finally { + await handle.close() + } +} + +function equal(left: string, right: string) { + const a = Buffer.from(left) + const b = Buffer.from(right) + return a.length === b.length && timingSafeEqual(a, b) +} + +function livePID(pid: number) { + if (!Number.isSafeInteger(pid) || pid < 1) throw new Error("Copilot bridge descriptor PID is invalid") + try { + process.kill(pid, 0) + } catch (cause) { + if (isRecord(cause) && cause.code === "EPERM") return + throw new Error("Copilot bridge descriptor process is not running") + } +} + +export async function connect(stateDirectory: string, fetchImplementation: typeof fetch = fetch): Promise { + if (!path.isAbsolute(stateDirectory)) throw new Error("Copilot bridge state directory must be absolute") + const root = path.resolve(stateDirectory) + await rejectSymlinks(root) + const stats = await lstat(root) + if (!stats.isDirectory()) throw new Error("Copilot bridge state directory must be a real directory") + assertOwned(stats.uid, "Copilot bridge state directory") + if (process.platform !== "win32" && (stats.mode & 0o777) !== 0o700) { + throw new Error("Copilot bridge state directory must have mode 0700") + } + const descriptor = Schema.decodeUnknownSync(Schema.fromJsonString(Descriptor))( + await privateFile(root, path.join(root, "connection.json"), "connection descriptor", 64 * 1024), + ) + livePID(descriptor.pid) + const rootURL = baseURL(descriptor.base_url) + if (!/^[A-Za-z0-9_-]{16,128}$/.test(descriptor.instance_id)) { + throw new Error("Copilot bridge descriptor instance ID is invalid") + } + const secret = capability( + ( + await privateFile(root, descriptor.capability_file, "client capability", 4096) + ).trim(), + ) + const challenge = randomBytes(32).toString("base64url") + const target = new URL(rootURL) + target.pathname = "/challenge" + const response = await fetchImplementation(target, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ challenge }), + credentials: "omit", + redirect: "manual", + referrerPolicy: "no-referrer", + }) + if (response.status >= 300 && response.status < 400) { + throw new RedirectError({ url: target.toString(), status: response.status }) + } + if (!response.ok) throw new Error(`Copilot bridge challenge failed with HTTP ${response.status}`) + const raw = await response.text() + if (Buffer.byteLength(raw, "utf8") > 4096) throw new Error("Copilot bridge challenge response is too large") + const result = Schema.decodeUnknownSync(Schema.fromJsonString(Challenge))(raw) + const proof = createHmac("sha256", secret).update(challenge).digest("base64url") + if (!equal(result.instance_id, descriptor.instance_id) || !equal(result.proof, proof)) { + throw new AuthenticationError({ message: "Copilot bridge challenge verification failed" }) + } + return Object.freeze({ + baseURL: rootURL, + instanceID: descriptor.instance_id, + pid: descriptor.pid, + capability: secret, + }) +} + +export interface Config { + readonly stateDirectory: string + readonly connect: () => Effect.Effect } -export function transport(input = defaultBaseURL, ports?: readonly number[]): Fetch { - const root = baseURL(input, ports) - const origin = new URL(root).origin +export class ConfigService extends Context.Service()("@opencode/CopilotBridgeConfig") {} + +export function config(stateDirectory: string): Config { + return ConfigService.of({ + stateDirectory, + connect: () => + Effect.tryPromise({ + try: () => connect(stateDirectory), + catch: (cause) => new ConnectionError({ message: "Unable to verify the local Copilot bridge", cause }), + }), + }) +} + +export const configLayer = Layer.effect( + ConfigService, + Global.Service.use((global) => + Effect.succeed( + config( + process.env[stateDirectoryEnvironment] ?? + process.env.COPILOT_BRIDGE_STATE_DIR ?? + path.join(global.state, "copilot-bridge"), + ), + ), + ), +) + +export const configNode = makeGlobalNode({ + name: "copilot-bridge-config", + service: ConfigService, + layer: configLayer, + deps: [Global.node], +}) + +export const configLayerWith = (input: Config) => Layer.succeed(ConfigService, ConfigService.of(input)) + +export function transport(connection: () => Promise): Fetch { return async (request, init) => { const target = new URL(request instanceof Request ? request.url : request) - if (target.origin !== origin || !["/v1/models", "/v1/responses"].includes(target.pathname)) { + if (target.hostname !== "127.0.0.1" || !["/v1/models", "/v1/responses"].includes(target.pathname)) { throw new InvalidURLError({ url: target.toString(), message: "Request escaped the Copilot bridge API" }) } if (target.username || target.password || target.search || target.hash) { throw new InvalidURLError({ url: target.toString(), message: "Request URL contains forbidden components" }) } + const active = await connection() const incoming = new Headers(init?.headers ?? (request instanceof Request ? request.headers : undefined)) const headers = new Headers() for (const name of ["accept", "content-type", "user-agent"]) { const value = incoming.get(name) if (value) headers.set(name, value) } - - const response = await fetch(request, { + headers.set("authorization", `Bearer ${active.capability}`) + headers.set("x-copilot-bridge-instance", active.instanceID) + const body = + init?.body ?? + (request instanceof Request && request.method !== "GET" && request.method !== "HEAD" + ? await request.clone().text() + : undefined) + const destination = `${active.baseURL}${target.pathname.slice("/v1".length)}` + + const response = await fetch(destination, { ...init, - body: target.pathname === "/v1/responses" ? responsesBody(init?.body) : init?.body, + body: target.pathname === "/v1/responses" ? responsesBody(body) : body, headers, credentials: "omit", redirect: "manual", referrerPolicy: "no-referrer", }) if (response.status >= 300 && response.status < 400) { - throw new RedirectError({ url: target.toString(), status: response.status }) + throw new RedirectError({ url: destination, status: response.status }) } return target.pathname === "/v1/responses" ? normalizeResponse(response) : response } @@ -215,8 +377,13 @@ function responsesBody(input: RequestInit["body"]) { if (typeof input !== "string") return input const decoded = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(input)) if (!isRecord(decoded) || !Array.isArray(decoded.input)) return input + if (decoded.input.some((item) => isRecord(item) && item.type === "item_reference")) { + throw new ResponseError({ message: "Copilot bridge requests must contain complete canonical history" }) + } + const { previous_response_id: _, conversation: __, store: ___, ...request } = decoded return JSON.stringify({ - ...decoded, + ...request, + store: false, input: decoded.input.map((item) => { if (!isRecord(item) || item.type !== undefined || typeof item.role !== "string") return item return { type: "message", ...item } @@ -295,6 +462,24 @@ function normalizeFrame(frame: string, state: { added: Set; responseID?: return frame } const item = record(event.item) + if (event.type === "response.output_item.done" && item?.type === "web_search_call") { + const sources = Array.isArray(item.results) + ? item.results.flatMap((result) => { + const source = record(result) + return source?.type === "url_citation" && typeof source.url === "string" + ? [{ type: "url", url: source.url }] + : [] + }) + : [] + const action = record(item.action) ?? { type: "search" } + return sse("response.output_item.done", { + ...event, + item: { + ...item, + action: sources.length ? { ...action, sources } : action, + }, + }) + } if (event.type === "response.output_item.added" && typeof item?.id === "string") { state.added.add(item.id) const outputIndex = finite(event.output_index) @@ -344,20 +529,16 @@ function sse(type: string, input: Record) { } export const catalog = Effect.fn("CopilotBridge.catalog")(function* (input: CatalogInput) { - const root = baseURL(input.baseURL, input.allowedPorts) - const cached = yield* readCache(input.cacheFile, root) + const cached = yield* readCache(input.cacheFile) const now = Date.now() - const cacheTTL = duration(input.cacheTTL, defaultCatalogTTL) - const staleTTL = duration(input.staleTTL, defaultStaleTTL) - if (!input.refresh && cached && now - cached.fetchedAt <= cacheTTL) { + if (!input.refresh && cached && now - cached.fetchedAt <= defaultCatalogTTL) { return { models: cached.models, source: "fresh-cache", fetchedAt: cached.fetchedAt } satisfies CatalogResult } - const remote = yield* fetchCatalog(root, input).pipe(Effect.exit) + const remote = yield* fetchCatalog(input).pipe(Effect.exit) if (Exit.isSuccess(remote)) { const next = { - version: 1 as const, - baseURL: root, + version: 2 as const, fetchedAt: now, models: remote.value, } @@ -367,26 +548,15 @@ export const catalog = Effect.fn("CopilotBridge.catalog")(function* (input: Cata return { models: next.models, source: "remote", fetchedAt: next.fetchedAt } satisfies CatalogResult } - if (cached && now - cached.fetchedAt <= staleTTL) { + if (cached && now - cached.fetchedAt <= defaultStaleTTL) { yield* Effect.logWarning("using stale Copilot bridge model catalog", { - baseURL: root, ageMs: now - cached.fetchedAt, cause: remote.cause, }) return { models: cached.models, source: "stale-cache", fetchedAt: cached.fetchedAt } satisfies CatalogResult } - const fallback = (input.fallbackModels ?? []).map((id) => normalizeModel({ id }, input)) - if (fallback.length) { - yield* Effect.logWarning("using explicitly configured Copilot bridge fallback models", { - baseURL: root, - models: fallback.map((model) => model.id), - cause: remote.cause, - }) - return { models: fallback, source: "fallback", fetchedAt: now } satisfies CatalogResult - } - - return yield* new CatalogUnavailableError({ baseURL: root, cause: Cause.squash(remote.cause) }) + return yield* new CatalogUnavailableError({ cause: Cause.squash(remote.cause) }) }) export const clearCatalog = Effect.fn("CopilotBridge.clearCatalog")(function* (cacheFile: string) { @@ -396,114 +566,6 @@ export const clearCatalog = Effect.fn("CopilotBridge.clearCatalog")(function* (c }) }) -export const search = Effect.fn("CopilotBridge.search")(function* (input: SearchInput) { - const root = baseURL(input.baseURL, input.allowedPorts) - const response = yield* Effect.tryPromise({ - try: (signal) => - transport(root, input.allowedPorts)(`${root}/responses`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - model: input.model, - instructions: - "You MUST use the enabled Copilot web_search tool before answering. Return a concise factual answer and preserve every source URL.", - input: [ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: input.query }], - }, - ], - tools: [{ type: "web_search_preview" }], - tool_choice: "required", - stream: true, - store: false, - ...(input.cacheKey ? { prompt_cache_key: input.cacheKey } : {}), - }), - signal: combineSignals(signal, input.signal), - }), - catch: (cause) => cause, - }) - if (!response.ok) { - const message = yield* Effect.promise(() => response.text()) - return yield* new ResponseError({ - status: response.status, - message: message || `Copilot bridge search failed with HTTP ${response.status}`, - }) - } - const body = yield* Effect.promise(() => response.text()) - return yield* parseSearch(body) -}) - -export const parseSearch = Effect.fn("CopilotBridge.parseSearch")(function* (body: string) { - const events = body - .split("\n") - .filter((line) => line.startsWith("data: ")) - .map((line) => line.slice(6).trim()) - .filter((line) => line && line !== "[DONE]") - .flatMap((line) => - Option.match(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(line), { - onNone: () => [], - onSome: (value) => [value], - }), - ) - .filter(isRecord) - - const failed = events.find((event) => event.type === "response.failed") - if (failed) { - const response = record(failed.response) - const error = record(response?.error) - return yield* new ResponseError({ - message: string(error?.message) ?? "Copilot bridge search failed", - }) - } - - const deltas = events - .filter((event) => event.type === "response.output_text.delta") - .map((event) => string(event.delta) ?? "") - .join("") - const completed = events - .filter((event) => event.type === "response.output_item.done") - .flatMap((event) => { - const item = record(event.item) - if (item?.type !== "message" || !Array.isArray(item.content)) return [] - return item.content.flatMap((part) => { - const value = record(part) - return value?.type === "output_text" && typeof value.text === "string" ? [value.text] : [] - }) - }) - .join("") - const text = deltas || completed - const responseID = events.flatMap((event) => { - const response = record(event.response) - return typeof response?.id === "string" ? [response.id] : [] - })[0] - return { - text, - citations: citations(events, text), - responseID, - } satisfies SearchResult -}) - -function loopback(hostname: string) { - if (hostname === "[::1]") return true - const octets = hostname.split(".").map(Number) - return ( - octets.length === 4 && - octets[0] === 127 && - octets.every((value) => Number.isInteger(value) && value >= 0 && value <= 255) - ) -} - -function duration(input: number | undefined, fallback: number) { - return typeof input === "number" && Number.isFinite(input) && input >= 0 ? input : fallback -} - -function combineSignals(effect: AbortSignal, external: AbortSignal | undefined) { - if (!external) return effect - return AbortSignal.any([effect, external]) -} - function isRecord(input: unknown): input is Record { return typeof input === "object" && input !== null && !Array.isArray(input) } @@ -563,43 +625,48 @@ function normalizeModel(input: unknown, defaults: CatalogInput): Model { const defaultEffort = string(item.defaultReasoningEffort) ?? string(item.default_reasoning_effort) return { id: ModelV2.ID.make(id), - name: string(item.name) ?? id, + name: string(item.display_name) ?? string(item.name) ?? id, family: family(id, capabilities?.family), releaseDate: releaseDate(id, item.version), contextLimit: + finite(item.context_window) ?? finite(limits?.max_context_window_tokens) ?? finite(limits?.max_prompt_tokens) ?? defaults.defaults?.contextLimit ?? defaultContextLimit, inputLimit: finite(limits?.max_prompt_tokens), - outputLimit: finite(limits?.max_output_tokens) ?? defaults.defaults?.outputLimit ?? defaultOutputLimit, + outputLimit: + finite(item.max_output_tokens) ?? + finite(limits?.max_output_tokens) ?? + defaults.defaults?.outputLimit ?? + defaultOutputLimit, reasoningEfforts: [...new Set(efforts)], defaultReasoningEffort: defaultEffort && efforts.includes(defaultEffort) ? defaultEffort : undefined, tools: bool(supports?.tool_calls) ?? defaults.defaults?.tools ?? true, - vision: bool(supports?.vision) ?? defaults.defaults?.vision ?? true, + vision: bool(item.supports_vision) ?? bool(supports?.vision) ?? defaults.defaults?.vision ?? true, structuredOutput: bool(supports?.structured_outputs) ?? defaults.defaults?.structuredOutput ?? true, } } -function fetchCatalog(root: string, input: CatalogInput) { +function fetchCatalog(input: CatalogInput) { return Effect.gen(function* () { const response = yield* Effect.tryPromise({ try: (signal) => - transport(root, input.allowedPorts)(`${root}/models`, { + transport(input.connect)(`${placeholderBaseURL}/models`, { headers: { accept: "application/json" }, signal: AbortSignal.any([signal, AbortSignal.timeout(5_000)]), }), catch: (cause) => cause, }) if (!response.ok) { - return yield* new CatalogUnavailableError({ baseURL: root, cause: new Error(`HTTP ${response.status}`) }) + return yield* new CatalogUnavailableError({ cause: new Error(`HTTP ${response.status}`) }) } const raw = yield* Effect.tryPromise({ try: () => response.json(), catch: (cause) => cause, }) const list = yield* Schema.decodeUnknownEffect(List)(raw).pipe( - Effect.mapError((cause) => new CatalogUnavailableError({ baseURL: root, cause })), + Effect.mapError((cause) => new CatalogUnavailableError({ cause })), ) const models = list.data .map((item) => normalizeModel(item, input)) @@ -610,13 +677,13 @@ function fetchCatalog(root: string, input: CatalogInput) { return state !== "disabled" && state !== "unconfigured" }) if (!models.length) { - return yield* new CatalogUnavailableError({ baseURL: root, cause: new Error("No enabled models returned") }) + return yield* new CatalogUnavailableError({ cause: new Error("No enabled models returned") }) } return models }) } -function readCache(cacheFile: string, root: string) { +function readCache(cacheFile: string) { return Effect.gen(function* () { const raw = yield* Effect.tryPromise({ try: () => readFile(cacheFile, "utf8"), @@ -624,7 +691,6 @@ function readCache(cacheFile: string, root: string) { }).pipe(Effect.catch(() => Effect.succeed(undefined))) if (raw === undefined) return undefined const parsed = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.fromJsonString(Cache))(raw)) - if (!parsed || parsed.baseURL !== root) return undefined return parsed }) } @@ -640,34 +706,3 @@ function writeCache(cacheFile: string, cache: Cache) { catch: (cause) => cause, }) } - -function citations(events: readonly Record[], text: string) { - const result = new Map() - const add = (url: unknown, title?: unknown) => { - if (typeof url !== "string" || !URL.canParse(url)) return - const parsed = new URL(url) - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return - const current = result.get(parsed.toString()) - result.set(parsed.toString(), { - url: parsed.toString(), - title: typeof title === "string" && title ? title : current?.title, - }) - } - - for (const event of events) { - const item = record(event.item) - if (!Array.isArray(item?.content)) continue - for (const part of item.content) { - const annotations = record(part)?.annotations - if (!Array.isArray(annotations)) continue - for (const annotation of annotations) { - const value = record(annotation) - if (value?.type !== "url_citation") continue - add(value.url, value.title) - } - } - } - for (const match of text.matchAll(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g)) add(match[2], match[1]) - for (const match of text.matchAll(/https?:\/\/[^\s<>()]+/g)) add(match[0]?.replace(/[.,;:!?]+$/, "")) - return [...result.values()] -} diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 804d16372182..d6096632fb6f 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -7,6 +7,7 @@ import { Effect, Layer, Scope } from "effect" import { AgentV2 } from "../agent" import { Catalog } from "../catalog" import { CommandV2 } from "../command" +import { CopilotBridge } from "../copilot-bridge" import { Config } from "../config" import { ConfigAgentPlugin } from "../config/plugin/agent" import { ConfigCommandPlugin } from "../config/plugin/command" @@ -39,6 +40,7 @@ export type Requirements = | AgentV2.Service | Catalog.Service | CommandV2.Service + | CopilotBridge.ConfigService | Config.Service | EventV2.Service | FileSystem.Service @@ -136,6 +138,7 @@ export const node = makeLocationNode({ layer, deps: [ Catalog.node, + CopilotBridge.configNode, CommandV2.node, PluginV2.node, Integration.node, diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 075a6ed093c6..680bdf1aa301 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -125,6 +125,7 @@ export const ModelsDevPlugin = define({ Effect.fn(function* (integrations) { const data = yield* modelsDev.get() for (const item of Object.values(data)) { + if (item.id === ProviderV2.ID.githubCopilot) continue if (item.env.length === 0) continue const integrationID = item.id integrations.update(integrationID, (integration) => (integration.name = item.name)) @@ -143,6 +144,7 @@ export const ModelsDevPlugin = define({ Effect.fn(function* (catalog) { const data = yield* modelsDev.get() for (const item of Object.values(data)) { + if (item.id === ProviderV2.ID.githubCopilot) continue const providerID = ProviderV2.ID.make(item.id) catalog.provider.update(providerID, (provider) => { provider.name = item.name diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 1e85fedf6cf8..4897b18054dc 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -9,7 +9,6 @@ import { CoherePlugin } from "./provider/cohere" import { DeepInfraPlugin } from "./provider/deepinfra" import { DynamicProviderPlugin } from "./provider/dynamic" import { GatewayPlugin } from "./provider/gateway" -import { GithubCopilotPlugin } from "./provider/github-copilot" import { GithubCopilotSDKPlugin } from "./provider/github-copilot-sdk" import { GitLabPlugin } from "./provider/gitlab" import { GooglePlugin } from "./provider/google" @@ -46,7 +45,6 @@ export const ProviderPlugins: PluginInternal.Plugin - Effect.logWarning("GitHub Copilot SDK bridge is unavailable", { - baseURL: connection.baseURL, - cause, - }).pipe(Effect.as(undefined)), - ), - ) + const result = yield* loadCatalog(global.cache, connection) if (!result) return - - catalog.provider.update(CopilotBridge.providerID, (provider) => { - provider.name = CopilotBridge.providerName - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai", - url: connection.baseURL, - settings: { redirect: "error" }, - } - provider.request.headers = {} - provider.request.body = {} - }) - - for (const remote of result.models) { - catalog.model.update(CopilotBridge.providerID, remote.id, (model) => { - model.name = remote.name - model.family = ModelV2.Family.make(remote.family) - model.api = { - id: remote.id, - type: "aisdk", - package: "@ai-sdk/openai", - url: connection.baseURL, - settings: { redirect: "error" }, - } - model.capabilities = { - tools: remote.tools, - input: remote.vision ? ["text", "image"] : ["text"], - output: ["text"], - } - model.variants = remote.reasoningEfforts.map((effort) => ({ - id: ModelV2.VariantID.make(effort), - headers: {}, - body: { reasoning: { effort } }, - })) - model.time.released = Date.parse(remote.releaseDate) || 0 - model.cost = [{ input: 0, output: 0, cache: { read: 0, write: 0 } }] - model.status = "active" - model.enabled = true - model.limit = { - context: remote.contextLimit, - input: remote.inputLimit, - output: remote.outputLimit, - } - }) - } + applyCatalog(catalog, result.models) }), ) yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== CopilotBridge.providerID) return - evt.language = evt.sdk.responses(evt.model.api.id) + evt.language = evt.sdk.responses(evt.model.id) }), ) }), @@ -107,77 +33,90 @@ export const GithubCopilotSDKPlugin = define({ export const GithubCopilotSDKSecurityPlugin = define({ id: "github-copilot-sdk-security", effect: Effect.fn(function* (ctx) { - const config = yield* Config.Service + const global = yield* Global.Service + const connection = yield* CopilotBridge.ConfigService yield* ctx.catalog.transform( Effect.fn(function* (catalog) { - const item = catalog.provider.get(CopilotBridge.providerID) - if (!item) return - const connection = resolveConnection( - Config.latest(yield* config.entries(), "providers")?.[CopilotBridge.providerID], - ) - catalog.provider.update(CopilotBridge.providerID, (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai", - url: connection.baseURL, - settings: { redirect: "error" }, - } - provider.request.headers = {} - for (const key of deploymentFields) delete provider.request.body[key] - }) - for (const model of item.models.values()) { - catalog.model.update(CopilotBridge.providerID, model.id, (draft) => { - draft.api = { - id: draft.api.id, - type: "aisdk", - package: "@ai-sdk/openai", - url: connection.baseURL, - settings: { redirect: "error" }, - } - draft.request.headers = {} - for (const key of deploymentFields) delete draft.request.body[key] - }) + catalog.provider.remove(ProviderV2.ID.githubCopilot) + const result = yield* loadCatalog(global.cache, connection) + if (!result) { + catalog.provider.remove(CopilotBridge.providerID) + return } + applyCatalog(catalog, result.models) }), ) }), }) -function resolveConnection(provider: ConfigProvider.Info | undefined) { - const body = provider?.request?.body - const settings = provider?.api?.settings - const configuredURL = - provider?.api?.url ?? - (typeof body?.baseURL === "string" ? body.baseURL : undefined) ?? - process.env.OPENCODE_COPILOT_BRIDGE_URL ?? - CopilotBridge.defaultBaseURL - const allowedPorts = CopilotBridge.allowedPorts( - settings?.allowedPorts ?? body?.allowedPorts ?? process.env.OPENCODE_COPILOT_BRIDGE_ALLOWED_PORTS, +function loadCatalog(cache: string, connection: CopilotBridge.Config) { + return CopilotBridge.catalog({ + connect: () => Effect.runPromise(connection.connect()), + cacheFile: path.join(cache, "github-copilot-sdk-models.json"), + }).pipe( + Effect.catch((cause) => + Effect.logWarning("GitHub Copilot SDK bridge is unavailable", { cause }).pipe(Effect.as(undefined)), + ), ) - const fallbackModels = settings?.fallbackModels ?? body?.fallbackModels - return { - baseURL: CopilotBridge.baseURL(configuredURL, allowedPorts), - allowedPorts, - cacheFile: - typeof settings?.catalogCacheFile === "string" - ? settings.catalogCacheFile - : typeof body?.catalogCacheFile === "string" - ? body.catalogCacheFile - : undefined, - cacheTTL: - typeof settings?.catalogCacheTTL === "number" - ? settings.catalogCacheTTL - : typeof body?.catalogCacheTTL === "number" - ? body.catalogCacheTTL - : undefined, - staleTTL: - typeof settings?.catalogStaleTTL === "number" - ? settings.catalogStaleTTL - : typeof body?.catalogStaleTTL === "number" - ? body.catalogStaleTTL - : undefined, - fallbackModels: Array.isArray(fallbackModels) - ? fallbackModels.filter((item: unknown): item is string => typeof item === "string") - : undefined, +} + +function applyCatalog(catalog: Catalog.Draft, models: readonly CopilotBridge.Model[]) { + const trusted = new Map(models.map((model) => [model.id, model])) + const existing = catalog.provider.get(CopilotBridge.providerID) + for (const model of existing?.models.values() ?? []) { + if (!trusted.has(model.id)) catalog.model.remove(CopilotBridge.providerID, model.id) + } + + catalog.provider.update(CopilotBridge.providerID, (provider) => { + provider.name = CopilotBridge.providerName + provider.integrationID = undefined + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai", + url: CopilotBridge.placeholderBaseURL, + settings: { redirect: "error" }, + } + provider.request.headers = {} + provider.request.body = {} + provider.disabled = false + }) + + for (const remote of models) { + catalog.model.update(CopilotBridge.providerID, remote.id, (model) => { + model.name = remote.name + model.family = ModelV2.Family.make(remote.family) + model.api = { + id: remote.id, + type: "aisdk", + package: "@ai-sdk/openai", + url: CopilotBridge.placeholderBaseURL, + settings: { redirect: "error" }, + } + model.capabilities = { + tools: remote.tools, + input: remote.vision ? ["text", "image"] : ["text"], + output: ["text"], + } + model.variants = remote.reasoningEfforts.map((effort) => ({ + id: ModelV2.VariantID.make(effort), + headers: {}, + body: { reasoning: { effort } }, + })) + model.request.headers = {} + model.request.body = {} + model.request.variant = + remote.defaultReasoningEffort === undefined + ? undefined + : ModelV2.VariantID.make(remote.defaultReasoningEffort) + model.time.released = Date.parse(remote.releaseDate) || 0 + model.cost = [{ input: 0, output: 0, cache: { read: 0, write: 0 } }] + model.status = "active" + model.enabled = true + model.limit = { + context: remote.contextLimit, + input: remote.inputLimit, + output: remote.outputLimit, + } + }) } } diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 0b467e3a48b5..eaaa4ea6b39e 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -200,7 +200,9 @@ const layer = Layer.effect( const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const context = entries.map((entry) => entry.message) const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps - const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions) + const toolMaterialization = isLastStep + ? undefined + : yield* tools.materialize(agent.info?.permissions, ProviderV2.ID.make(model.provider)) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ model, diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 553a360cb4be..8fb26cb18afd 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -70,6 +70,8 @@ export type Error = | ModelUnavailableError | VariantUnavailableError | UnsupportedApiError + | CopilotBridge.AuthenticationError + | CopilotBridge.ConnectionError | Integration.AuthorizationError export interface Interface { @@ -136,19 +138,41 @@ const apiName = (model: ModelV2.Info) => export const fromCatalogModel = ( model: ModelV2.Info, credential?: Credential.Value, -): Effect.Effect => { - const resolved = - credential?.type !== "key" || credential.metadata === undefined + connection?: CopilotBridge.Connection, +): Effect.Effect => { + const bridge = model.providerID === CopilotBridge.providerID + if (bridge && !connection) { + return Effect.fail(new CopilotBridge.AuthenticationError({ message: "Copilot bridge connection is unavailable" })) + } + const resolved = bridge + ? produce(model, (draft) => { + draft.api = { + id: draft.id, + type: "aisdk", + package: "@ai-sdk/openai", + url: connection.baseURL, + settings: { redirect: "error" }, + } + draft.request.headers = { "x-copilot-bridge-instance": connection.instanceID } + draft.request.body = Object.fromEntries( + Object.entries(draft.request.body).filter(([key]) => + ["max_output_tokens", "parallel_tool_calls", "reasoning", "temperature", "text", "top_p"].includes(key), + ), + ) + }) + : credential?.type !== "key" || credential.metadata === undefined ? model : produce(model, (draft) => { Object.assign(draft.request.body, credential.metadata) }) - const key = resolved.providerID === CopilotBridge.providerID ? undefined : apiKey(resolved, credential) + const key = bridge ? undefined : apiKey(resolved, credential) if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") { return Effect.succeed( withDefaults(resolved, OpenAIResponses.route) - .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: resolved.api.id }), + .with({ + auth: bridge ? Auth.bearer(Auth.value(connection.capability)) : key === undefined ? Auth.none : Auth.bearer(key), + }) + .model({ id: bridge ? resolved.id : resolved.api.id }), ) } if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") { @@ -174,8 +198,15 @@ export const fromCatalogModel = ( ) } -export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, credential?: Credential.Value) => - withVariant(model, session.model?.variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential))) +export const resolve = ( + session: SessionSchema.Info, + model: ModelV2.Info, + credential?: Credential.Value, + connection?: CopilotBridge.Connection, +) => + withVariant(model, session.model?.variant).pipe( + Effect.flatMap((model) => fromCatalogModel(model, credential, connection)), + ) export const supported = (model: ModelV2.Info) => model.api.type === "aisdk" && @@ -189,6 +220,7 @@ export const locationLayer = Layer.effect( Effect.gen(function* () { const catalog = yield* Catalog.Service const integrations = yield* Integration.Service + const bridge = yield* CopilotBridge.ConfigService return Service.of({ resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { // Location plugins populate and filter the catalog asynchronously during layer startup. @@ -207,17 +239,26 @@ export const locationLayer = Layer.effect( }) if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id }) const provider = yield* catalog.provider.get(selected.providerID) - const connection = yield* integrations.connection.active( - provider?.integrationID ?? Integration.ID.make(selected.providerID), - ) + const bridgeConnection = selected.providerID === CopilotBridge.providerID ? yield* bridge.connect() : undefined + const connection = + bridgeConnection === undefined + ? yield* integrations.connection.active( + provider?.integrationID ?? Integration.ID.make(selected.providerID), + ) + : undefined return yield* resolve( session, selected, connection ? yield* integrations.connection.resolve(connection) : undefined, + bridgeConnection, ) }), }) }), ) -export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Catalog.node, Integration.node] }) +export const node = makeLocationNode({ + service: Service, + layer: locationLayer, + deps: [Catalog.node, Integration.node, CopilotBridge.configNode], +}) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index ff5e5bcc9214..5b94189bac74 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -14,6 +14,7 @@ import { Tools } from "./tools" import { makeLocationNode } from "../effect/app-node" import type { ModelV2 } from "../model" import type { ProviderV2 } from "../provider" +import { CopilotBridge } from "../copilot-bridge" export type ExecuteInput = { readonly sessionID: SessionSchema.ID @@ -28,7 +29,10 @@ export type ExecuteInput = { } export interface Interface { - readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect + readonly materialize: ( + permissions?: PermissionV2.Ruleset, + providerID?: ProviderV2.ID, + ) => Effect.Effect /** Internal registration capability exposed publicly only through Tools.Service. */ readonly register: (tools: Readonly>) => Effect.Effect } @@ -111,7 +115,7 @@ const registryLayer = Layer.effect( }), ) }), - materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions = []) { + materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions = [], providerID) { const registrations = new Map(applications.entries()) for (const [name, entries] of local) { const registration = entries.at(-1)?.registration @@ -119,6 +123,7 @@ const registryLayer = Layer.effect( } for (const [name, registration] of registrations) if (whollyDisabled(permission(registration.tool, name), permissions)) registrations.delete(name) + if (providerID !== CopilotBridge.providerID) registrations.delete("websearch") return { definitions: Array.from(registrations, ([name, registration]) => definition(name, registration.tool)), settle: (input) => { diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index d9ac94d35041..ced9c8a3f5d8 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -52,6 +52,7 @@ type Config< readonly description: string readonly input: Input readonly output: Output + readonly native?: Record readonly structured?: Structured readonly toStructuredOutput?: (input: { readonly input: Schema.Schema.Type
- -
- - - - ) -} - -function ProviderPicker(props: { - directory?: Accessor - onSelect: (provider: string) => void - onPrepare?: () => void -}) { - const settings = useSettings() - if (settings.general.newLayoutDesigns()) - return - const providers = useProviders(props.directory) - const language = useLanguage() - const popularGroup = () => language.t("dialog.provider.group.popular") - const otherGroup = () => language.t("dialog.provider.group.other") - const customLabel = () => language.t("settings.providers.tag.custom") - const note = (id: string) => { - if (id === "anthropic") return language.t("dialog.provider.anthropic.note") - if (id === "openai") return language.t("dialog.provider.openai.note") - if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") - if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline") - return undefined - } - - return ( - x?.id} - items={() => { - language.locale() - return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()] - }} - filterKeys={["id", "name"]} - groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} - sortBy={(a, b) => { - if (a.id === CUSTOM_ID) return -1 - if (b.id === CUSTOM_ID) return 1 - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - sortGroupsBy={(a, b) => { - const popular = popularGroup() - if (a.category === popular && b.category !== popular) return -1 - if (b.category === popular && a.category !== popular) return 1 - return 0 - }} - onSelect={(x) => { - if (!x) return - props.onSelect(x.id) - }} - > - {(i) => ( -
- - {i.name} - -
{language.t("dialog.provider.opencode.tagline")}
-
- - {language.t("settings.providers.tag.custom")} - - - {language.t("dialog.provider.tag.recommended")} - - {(value) =>
{value()}
}
- - {language.t("dialog.provider.tag.recommended")} - -
- )} -
- ) -} - -function ProviderPickerV2(props: { - directory?: Accessor - onSelect: (provider: string) => void - onPrepare?: () => void -}) { - const providers = useProviders(props.directory) +}> = () => { const language = useLanguage() - const serverSync = useServerSync() - const serverSDK = useServerSDK() - const [store, setStore] = createStore({ - filter: "", - active: undefined as string | undefined, - connecting: undefined as string | undefined, - }) - const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"] - const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") }) - const all = createMemo(() => { - language.locale() - const query = store.filter.trim().toLowerCase() - const values = [custom(), ...providers.all().values()] - if (!query) return values - return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query)) - }) - const popular = createMemo(() => - all() - .filter((provider) => featured.includes(provider.id)) - .sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)), - ) - const other = createMemo(() => - all() - .filter((provider) => !featured.includes(provider.id)) - .sort((a, b) => { - if (a.id === CUSTOM_ID) return -1 - if (b.id === CUSTOM_ID) return 1 - return a.name.localeCompare(b.name) - }), - ) - const rows = createMemo(() => [...popular(), ...other()]) - let picker: HTMLDivElement | undefined - let search: HTMLInputElement | undefined - - onMount(() => search?.focus({ preventScroll: true })) - - const connect = (provider: string) => { - props.onPrepare?.() - if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) { - props.onSelect(provider) - return - } - if (store.connecting) return - setStore("connecting", provider) - void serverSDK() - .client.provider.auth() - .then((response) => { - serverSync().set("provider_auth", response.data ?? {}) - props.onSelect(provider) - }) - .catch(() => props.onSelect(provider)) - } - - const move = (event: KeyboardEvent, direction: number) => { - const items = rows() - if (items.length === 0) return - const index = items.findIndex((provider) => provider.id === store.active) - const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length - setStore("active", items[next].id) - picker - ?.querySelector(`[data-provider-id="${CSS.escape(items[next].id)}"]`) - ?.focus({ preventScroll: true }) - event.preventDefault() - } - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "ArrowDown") return move(event, 1) - if (event.key === "ArrowUp") return move(event, -1) - if (event.key !== "Enter" || !store.active) return - connect(store.active) - event.preventDefault() - } - return ( -
-
- } - placeholder={language.t("dialog.provider.search.placeholder")} - value={store.filter} - onInput={(event) => { - setStore({ filter: event.currentTarget.value, active: undefined }) - }} - /> -
-
-
- - {(group) => ( - 0}> -
-
- {group.title} -
- - {(provider) => ( - - )} - -
-
- )} -
- -
- {language.t("dialog.provider.empty")} -
-
-
-
-
-
- ) -} - -function ProviderConnection(props: { - provider: string - directory?: Accessor - onBack: () => void - setBack: (handler: () => void) => void -}) { - const dialog = useDialog() - const serverSync = useServerSync() - const serverSDK = useServerSDK() - const language = useLanguage() - const settings = useSettings() - const newLayout = settings.general.newLayoutDesigns - const providers = useProviders(props.directory) - - const alive = { value: true } - const timer = { current: undefined as ReturnType | undefined } - - onCleanup(() => { - alive.value = false - if (timer.current === undefined) return - clearTimeout(timer.current) - timer.current = undefined - }) - - const provider = createMemo( - () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, - ) - const fallback = createMemo(() => [ - { - type: "api" as const, - label: language.t("provider.connect.method.apiKey"), - }, - ]) - const [auth] = createResource( - () => props.provider, - async () => { - const cached = serverSync().data.provider_auth[props.provider] - if (cached) return cached - const res = await serverSDK().client.provider.auth() - if (!alive.value) return fallback() - serverSync().set("provider_auth", res.data ?? {}) - return res.data?.[props.provider] ?? fallback() - }, - ) - const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) - const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) - const cachedMethods = serverSync().data.provider_auth[props.provider] - const directMethod = - cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined - const [store, setStore] = createStore({ - methodIndex: directMethod as undefined | number, - authorization: undefined as undefined | ProviderAuthAuthorization, - promptInputs: undefined as undefined | Record, - state: (directMethod === undefined ? "pending" : undefined) as - | undefined - | "pending" - | "complete" - | "error" - | "prompt", - error: undefined as string | undefined, - }) - - type Action = - | { type: "method.select"; index: number } - | { type: "method.reset" } - | { type: "auth.prompt" } - | { type: "auth.inputs"; inputs: Record } - | { type: "auth.pending" } - | { type: "auth.complete"; authorization: ProviderAuthAuthorization } - | { type: "auth.error"; error: string } - - function dispatch(action: Action) { - setStore( - produce((draft) => { - if (action.type === "method.select") { - draft.methodIndex = action.index - draft.authorization = undefined - draft.promptInputs = undefined - draft.state = undefined - draft.error = undefined - return - } - if (action.type === "method.reset") { - draft.methodIndex = undefined - draft.authorization = undefined - draft.promptInputs = undefined - draft.state = undefined - draft.error = undefined - return - } - if (action.type === "auth.prompt") { - draft.state = "prompt" - draft.error = undefined - return - } - if (action.type === "auth.inputs") { - draft.promptInputs = action.inputs - draft.state = undefined - draft.error = undefined - return - } - if (action.type === "auth.pending") { - draft.state = "pending" - draft.error = undefined - return - } - if (action.type === "auth.complete") { - draft.state = "complete" - draft.authorization = action.authorization - draft.error = undefined - return - } - draft.state = "error" - draft.error = action.error - }), - ) - } - - const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined)) - - const methodLabel = (value?: { type?: string; label?: string }) => { - if (!value) return "" - if (value.type === "api") return language.t("provider.connect.method.apiKey") - return value.label ?? "" - } - - const methodDetails = (value?: { type?: string; label?: string }) => { - const label = methodLabel(value) - const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i) - const hint = suffix?.[1] - return { - label: suffix ? label.slice(0, -suffix[0].length) : label, - hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined, - } - } - - function formatError(value: unknown, fallback: string): string { - if (value && typeof value === "object" && "data" in value) { - const data = (value as { data?: { message?: unknown } }).data - if (typeof data?.message === "string" && data.message) return data.message - } - if (value && typeof value === "object" && "error" in value) { - const nested = formatError((value as { error?: unknown }).error, "") - if (nested) return nested - } - if (value && typeof value === "object" && "message" in value) { - const message = (value as { message?: unknown }).message - if (typeof message === "string" && message) return message - } - if (value instanceof Error && value.message) return value.message - if (typeof value === "string" && value) return value - return fallback - } - - async function selectMethod(index: number, inputs?: Record) { - if (timer.current !== undefined) { - clearTimeout(timer.current) - timer.current = undefined - } - - const method = methods()[index] - dispatch({ type: "method.select", index }) - - if (method.type === "api" && method.prompts?.length) { - if (!inputs) { - dispatch({ type: "auth.prompt" }) - return - } - dispatch({ type: "auth.inputs", inputs }) - return - } - - if (method.type === "oauth") { - if (method.prompts?.length && !inputs) { - dispatch({ type: "auth.prompt" }) - return - } - dispatch({ type: "auth.pending" }) - const start = Date.now() - await serverSDK() - .client.provider.oauth.authorize( - { - providerID: props.provider, - method: index, - inputs, - }, - { throwOnError: true }, - ) - .then((x) => { - if (!alive.value) return - const elapsed = Date.now() - start - const delay = 1000 - elapsed - - if (delay > 0) { - if (timer.current !== undefined) clearTimeout(timer.current) - timer.current = setTimeout(() => { - timer.current = undefined - if (!alive.value) return - dispatch({ type: "auth.complete", authorization: x.data! }) - }, delay) - return - } - dispatch({ type: "auth.complete", authorization: x.data! }) - }) - .catch((e) => { - if (!alive.value) return - dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) }) - }) - } - } - - function AuthPromptsView() { - const [formStore, setFormStore] = createStore({ - value: {} as Record, - index: 0, - }) - - const prompts = createMemo>(() => { - const value = method() - return value?.prompts ?? [] - }) - const matches = (prompt: NonNullable[number]>, value: Record) => { - if (!prompt.when) return true - const actual = value[prompt.when.key] - if (actual === undefined) return false - return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value - } - const current = createMemo(() => { - const all = prompts() - const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value)) - if (index === -1) return - return { - index, - prompt: all[index], - } - }) - const valid = createMemo(() => { - const item = current() - if (!item || item.prompt.type !== "text") return false - const value = formStore.value[item.prompt.key] ?? "" - return value.trim().length > 0 - }) - - async function next(index: number, value: Record) { - if (store.methodIndex === undefined) return - const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value)) - if (next !== -1) { - setFormStore("index", next) - return - } - if (method()?.type === "api") { - dispatch({ type: "auth.inputs", inputs: value }) - return - } - await selectMethod(store.methodIndex, value) - } - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - const item = current() - if (!item || item.prompt.type !== "text") return - if (!valid()) return - await next(item.index, formStore.value) - } - - const item = () => current() - const text = createMemo(() => { - const prompt = item()?.prompt - if (!prompt || prompt.type !== "text") return - return prompt - }) - const select = createMemo(() => { - const prompt = item()?.prompt - if (!prompt || prompt.type !== "select") return - return prompt - }) - - return ( -
- - - { - const prompt = text() - if (!prompt) return - setFormStore("value", prompt.key, value) - }} - /> - - - -
-
{select()?.message}
-
- x.value} - current={select()?.options.find((x) => x.value === formStore.value[select()!.key])} - onSelect={(value) => { - if (!value) return - const prompt = select() - if (!prompt) return - const nextValue = { - ...formStore.value, - [prompt.key]: value.value, - } - setFormStore("value", prompt.key, value.value) - void next(item()!.index, nextValue) - }} - > - {(option) => ( -
-
- - {option.label} - {option.hint} -
- )} - -
-
- - - - ) - } - - let listRef: ListRef | undefined - function handleKey(e: KeyboardEvent) { - if (e.key === "Enter" && e.target instanceof HTMLInputElement) { - return - } - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - let auto = false - createEffect(() => { - if (auto) return - if (loading()) return - if (methods().length === 1) { - auto = true - void selectMethod(0) - } - }) - - async function complete() { - await serverSDK().client.global.dispose() - dialog.close() - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("provider.connect.toast.connected.title", { provider: provider().name }), - description: language.t("provider.connect.toast.connected.description", { provider: provider().name }), - }) - } - - function goBack() { - if (methods().length > 1 && store.methodIndex !== undefined) { - dispatch({ type: "method.reset" }) - return - } - props.onBack() - } - - props.setBack(goBack) - - function MethodSelection() { - if (newLayout()) - return ( -
-
- {language.t("provider.connect.selectMethod", { provider: provider().name })} -
-
- - {(item, index) => { - const details = () => methodDetails(item) - return ( - - ) - }} - -
-
- ) - - return ( - <> -
- {language.t("provider.connect.selectMethod", { provider: provider().name })} -
-
- { - listRef = ref - }} - items={methods} - key={(m) => m?.label} - onSelect={async (selected, index) => { - if (!selected) return - void selectMethod(index) - }} - > - {(i) => ( -
-
- - {methodLabel(i)} -
- )} - -
- - ) - } - - function ApiAuthView() { - let apiKey: HTMLInputElement | undefined - const errorID = createUniqueId() - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - onMount(() => { - if (!newLayout()) return - apiKey?.focus({ preventScroll: true }) - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const apiKey = formData.get("apiKey") as string - - if (!apiKey?.trim()) { - setFormStore("error", language.t("provider.connect.apiKey.required")) - return - } - - setFormStore("error", undefined) - await serverSDK().client.auth.set({ - providerID: props.provider, - auth: { - type: "api", - key: apiKey, - ...(store.promptInputs ? { metadata: store.promptInputs } : {}), - }, - }) - await complete() - } - - if (newLayout()) - return ( -
- -
-
{language.t("provider.connect.opencodeZen.line1")}
-
{language.t("provider.connect.opencodeZen.line2")}
-
- {language.t("provider.connect.opencodeZen.visit.prefix")} - - {language.t("provider.connect.opencodeZen.visit.link")} - - {language.t("provider.connect.opencodeZen.visit.suffix")} -
-
-
-
- - - {(error) => ( - - )} - - - {language.t("common.continue")} - -
-
- ) - - return ( -
- - -
-
{language.t("provider.connect.opencodeZen.line1")}
-
{language.t("provider.connect.opencodeZen.line2")}
-
- {language.t("provider.connect.opencodeZen.visit.prefix")} - - {language.t("provider.connect.opencodeZen.visit.link")} - - {language.t("provider.connect.opencodeZen.visit.suffix")} -
-
-
- -
- {language.t("provider.connect.apiKey.description", { provider: provider().name })} -
-
-
-
- setFormStore("value", v)} - validationState={formStore.error ? "invalid" : undefined} - error={formStore.error} - /> - - -
- ) - } - - function OAuthCodeView() { - let codeInput: HTMLInputElement | undefined - const errorID = createUniqueId() - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - onMount(() => { - if (!newLayout()) return - codeInput?.focus({ preventScroll: true }) - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const code = formData.get("code") as string - - if (!code?.trim()) { - setFormStore("error", language.t("provider.connect.oauth.code.required")) - return - } - - setFormStore("error", undefined) - const result = await serverSDK() - .client.provider.oauth.callback({ - providerID: props.provider, - method: store.methodIndex, - code, - }) - .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) - .catch((error) => ({ ok: false as const, error })) - if (result.ok) { - await complete() - return - } - setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid"))) - } - - if (newLayout()) - return ( -
-
- {language.t("provider.connect.oauth.code.visit.prefix")} - - {language.t("provider.connect.oauth.code.visit.link")} - - {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} + +
+ +
+
GitHub Copilot SDK
+
{language.t("dialog.provider.copilot.note")}
+
+ Ready through the authenticated local SDK bridge. No OpenCode sign-in is required.
-
- - - {(error) => ( - - )} - - - {language.t("common.continue")} - -
-
- ) - - return ( -
-
- {language.t("provider.connect.oauth.code.visit.prefix")} - {language.t("provider.connect.oauth.code.visit.link")} - {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} -
-
- setFormStore("value", v)} - validationState={formStore.error ? "invalid" : undefined} - error={formStore.error} - /> - - -
- ) - } - - function OAuthAutoView() { - const code = createMemo(() => { - const instructions = store.authorization?.instructions - if (instructions?.includes(":")) { - return instructions.split(":").pop()?.trim() - } - return instructions - }) - - onMount(() => { - void (async () => { - const result = await serverSDK() - .client.provider.oauth.callback({ - providerID: props.provider, - method: store.methodIndex, - }) - .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) - .catch((error) => ({ ok: false as const, error })) - - if (!alive.value) return - - if (!result.ok) { - const message = formatError(result.error, language.t("common.requestFailed")) - dispatch({ type: "auth.error", error: message }) - return - } - - await complete() - })() - }) - - return ( -
-
- {language.t("provider.connect.oauth.auto.visit.prefix")} - {language.t("provider.connect.oauth.auto.visit.link")} - {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })} -
- -
- - {language.t("provider.connect.status.waiting")} -
-
- ) - } - - return ( -
-
- -
- - - {language.t("provider.connect.title.anthropicProMax")} - - {language.t("provider.connect.title", { provider: provider().name })} - -
-
-
-
- - -
-
- - {language.t("provider.connect.status.inProgress")} -
-
-
- - - - -
-
- - {language.t("provider.connect.status.inProgress")} -
-
-
- - - - -
-
- - {language.t("provider.connect.status.failed", { error: store.error ?? "" })} -
-
-
- - - - - - - - - - - - - -
-
+
) } diff --git a/packages/app/src/components/dialog-custom-provider-form.ts b/packages/app/src/components/dialog-custom-provider-form.ts deleted file mode 100644 index e26dcb09710d..000000000000 --- a/packages/app/src/components/dialog-custom-provider-form.ts +++ /dev/null @@ -1,158 +0,0 @@ -const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ -const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" - -type Translator = (key: string, vars?: Record) => string - -export type ModelErr = { - id?: string - name?: string -} - -export type HeaderErr = { - key?: string - value?: string -} - -export type ModelRow = { - row: string - id: string - name: string - err: ModelErr -} - -export type HeaderRow = { - row: string - key: string - value: string - err: HeaderErr -} - -export type FormState = { - providerID: string - name: string - baseURL: string - apiKey: string - models: ModelRow[] - headers: HeaderRow[] - err: { - providerID?: string - name?: string - baseURL?: string - } -} - -type ValidateArgs = { - form: FormState - t: Translator - disabledProviders: string[] - existingProviderIDs: Set -} - -export function validateCustomProvider(input: ValidateArgs) { - const providerID = input.form.providerID.trim() - const name = input.form.name.trim() - const baseURL = input.form.baseURL.trim() - const apiKey = input.form.apiKey.trim() - - const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() - const key = apiKey && !env ? apiKey : undefined - - const idError = !providerID - ? input.t("provider.custom.error.providerID.required") - : !PROVIDER_ID.test(providerID) - ? input.t("provider.custom.error.providerID.format") - : undefined - - const nameError = !name ? input.t("provider.custom.error.name.required") : undefined - const urlError = !baseURL - ? input.t("provider.custom.error.baseURL.required") - : !/^https?:\/\//.test(baseURL) - ? input.t("provider.custom.error.baseURL.format") - : undefined - - const disabled = input.disabledProviders.includes(providerID) - const existsError = idError - ? undefined - : input.existingProviderIDs.has(providerID) && !disabled - ? input.t("provider.custom.error.providerID.exists") - : undefined - - const seenModels = new Set() - const models = input.form.models.map((m) => { - const id = m.id.trim() - const idError = !id - ? input.t("provider.custom.error.required") - : seenModels.has(id) - ? input.t("provider.custom.error.duplicate") - : (() => { - seenModels.add(id) - return undefined - })() - const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined - return { id: idError, name: nameError } - }) - const modelsValid = models.every((m) => !m.id && !m.name) - const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }])) - - const seenHeaders = new Set() - const headers = input.form.headers.map((h) => { - const key = h.key.trim() - const value = h.value.trim() - - if (!key && !value) return {} - const keyError = !key - ? input.t("provider.custom.error.required") - : seenHeaders.has(key.toLowerCase()) - ? input.t("provider.custom.error.duplicate") - : (() => { - seenHeaders.add(key.toLowerCase()) - return undefined - })() - const valueError = !value ? input.t("provider.custom.error.required") : undefined - return { key: keyError, value: valueError } - }) - const headersValid = headers.every((h) => !h.key && !h.value) - const headerConfig = Object.fromEntries( - input.form.headers - .map((h) => ({ key: h.key.trim(), value: h.value.trim() })) - .filter((h) => !!h.key && !!h.value) - .map((h) => [h.key, h.value]), - ) - - const err = { - providerID: idError ?? existsError, - name: nameError, - baseURL: urlError, - } - - const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid - if (!ok) return { err, models, headers } - - return { - err, - models, - headers, - result: { - providerID, - name, - key, - config: { - npm: OPENAI_COMPATIBLE, - name, - ...(env ? { env: [env] } : {}), - options: { - baseURL, - ...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}), - }, - models: modelConfig, - }, - }, - } -} - -let row = 0 - -const nextRow = () => `row-${row++}` - -export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} }) -export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} }) diff --git a/packages/app/src/components/dialog-custom-provider.test.ts b/packages/app/src/components/dialog-custom-provider.test.ts deleted file mode 100644 index 07dd26ecd679..000000000000 --- a/packages/app/src/components/dialog-custom-provider.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { validateCustomProvider } from "./dialog-custom-provider-form" - -const t = (key: string) => key - -describe("validateCustomProvider", () => { - test("builds trimmed config payload", () => { - const result = validateCustomProvider({ - form: { - providerID: "custom-provider", - name: " Custom Provider ", - baseURL: "https://api.example.com ", - apiKey: " {env: CUSTOM_PROVIDER_KEY} ", - models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }], - headers: [ - { row: "h0", key: " X-Test ", value: " enabled ", err: {} }, - { row: "h1", key: "", value: "", err: {} }, - ], - err: {}, - }, - t, - disabledProviders: [], - existingProviderIDs: new Set(), - }) - - expect(result.result).toEqual({ - providerID: "custom-provider", - name: "Custom Provider", - key: undefined, - config: { - npm: "@ai-sdk/openai-compatible", - name: "Custom Provider", - env: ["CUSTOM_PROVIDER_KEY"], - options: { - baseURL: "https://api.example.com", - headers: { - "X-Test": "enabled", - }, - }, - models: { - "model-a": { name: "Model A" }, - }, - }, - }) - }) - - test("flags duplicate rows and allows reconnecting disabled providers", () => { - const result = validateCustomProvider({ - form: { - providerID: "custom-provider", - name: "Provider", - baseURL: "https://api.example.com", - apiKey: "secret", - models: [ - { row: "m0", id: "model-a", name: "Model A", err: {} }, - { row: "m1", id: "model-a", name: "Model A 2", err: {} }, - ], - headers: [ - { row: "h0", key: "Authorization", value: "one", err: {} }, - { row: "h1", key: "authorization", value: "two", err: {} }, - ], - err: {}, - }, - t, - disabledProviders: ["custom-provider"], - existingProviderIDs: new Set(["custom-provider"]), - }) - - expect(result.result).toBeUndefined() - expect(result.err.providerID).toBeUndefined() - expect(result.models[1]).toEqual({ - id: "provider.custom.error.duplicate", - name: undefined, - }) - expect(result.headers[1]).toEqual({ - key: "provider.custom.error.duplicate", - value: undefined, - }) - }) -}) diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx deleted file mode 100644 index 9e04cd83ad5d..000000000000 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { Dialog } from "@opencode-ai/ui/dialog" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { useMutation } from "@tanstack/solid-query" -import { TextField } from "@opencode-ai/ui/text-field" -import { showToast } from "@/utils/toast" -import { batch, For } from "solid-js" -import { createStore, produce } from "solid-js/store" -import { Link } from "@/components/link" -import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" -import { useLanguage } from "@/context/language" -import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" - -type Props = { - onBack: () => void -} - -export function DialogCustomProvider(props: Props) { - const language = useLanguage() - - return ( - - } - transition - > - - - ) -} - -export function CustomProviderForm(props: { autofocus?: boolean } = {}) { - const dialog = useDialog() - const serverSync = useServerSync() - const serverSDK = useServerSDK() - const language = useLanguage() - - const [form, setForm] = createStore({ - providerID: "", - name: "", - baseURL: "", - apiKey: "", - models: [modelRow()], - headers: [headerRow()], - err: {}, - }) - - const addModel = () => { - setForm( - "models", - produce((rows) => { - rows.push(modelRow()) - }), - ) - } - - const removeModel = (index: number) => { - if (form.models.length <= 1) return - setForm( - "models", - produce((rows) => { - rows.splice(index, 1) - }), - ) - } - - const addHeader = () => { - setForm( - "headers", - produce((rows) => { - rows.push(headerRow()) - }), - ) - } - - const removeHeader = (index: number) => { - if (form.headers.length <= 1) return - setForm( - "headers", - produce((rows) => { - rows.splice(index, 1) - }), - ) - } - - const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => { - setForm(key, value) - if (key === "apiKey") return - setForm("err", key, undefined) - } - - const setModel = (index: number, key: "id" | "name", value: string) => { - batch(() => { - setForm("models", index, key, value) - setForm("models", index, "err", key, undefined) - }) - } - - const setHeader = (index: number, key: "key" | "value", value: string) => { - batch(() => { - setForm("headers", index, key, value) - setForm("headers", index, "err", key, undefined) - }) - } - - const validate = () => { - const output = validateCustomProvider({ - form, - t: language.t, - disabledProviders: serverSync().data.config.disabled_providers ?? [], - existingProviderIDs: new Set(serverSync().data.provider.all.keys()), - }) - batch(() => { - setForm("err", output.err) - output.models.forEach((err, index) => setForm("models", index, "err", err)) - output.headers.forEach((err, index) => setForm("headers", index, "err", err)) - }) - return output.result - } - - const saveMutation = useMutation(() => ({ - mutationFn: async (result: NonNullable>) => { - const disabledProviders = serverSync().data.config.disabled_providers ?? [] - const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) - - if (result.key) { - await serverSDK().client.auth.set({ - providerID: result.providerID, - auth: { - type: "api", - key: result.key, - }, - }) - } - - await serverSync().updateConfig({ - provider: { [result.providerID]: result.config }, - disabled_providers: nextDisabled, - }) - return result - }, - onSuccess: (result) => { - dialog.close() - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("provider.connect.toast.connected.title", { provider: result.name }), - description: language.t("provider.connect.toast.connected.description", { provider: result.name }), - }) - }, - onError: (err) => { - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - }, - })) - - const save = (e: SubmitEvent) => { - e.preventDefault() - if (saveMutation.isPending) return - - const result = validate() - if (!result) return - saveMutation.mutate(result) - } - - return ( -
-
- -
{language.t("provider.custom.title")}
-
- -
-

- {language.t("provider.custom.description.prefix")} - - {language.t("provider.custom.description.link")} - - {language.t("provider.custom.description.suffix")} -

- -
- setField("providerID", v)} - validationState={form.err.providerID ? "invalid" : undefined} - error={form.err.providerID} - /> - setField("name", v)} - validationState={form.err.name ? "invalid" : undefined} - error={form.err.name} - /> - setField("baseURL", v)} - validationState={form.err.baseURL ? "invalid" : undefined} - error={form.err.baseURL} - /> - setField("apiKey", v)} - /> -
- -
- - - {(m, i) => ( -
-
- setModel(i(), "id", v)} - validationState={m.err.id ? "invalid" : undefined} - error={m.err.id} - /> -
-
- setModel(i(), "name", v)} - validationState={m.err.name ? "invalid" : undefined} - error={m.err.name} - /> -
- removeModel(i())} - disabled={form.models.length <= 1} - aria-label={language.t("provider.custom.models.remove")} - /> -
- )} -
- -
- -
- - - {(h, i) => ( -
-
- setHeader(i(), "key", v)} - validationState={h.err.key ? "invalid" : undefined} - error={h.err.key} - /> -
-
- setHeader(i(), "value", v)} - validationState={h.err.value ? "invalid" : undefined} - error={h.err.value} - /> -
- removeHeader(i())} - disabled={form.headers.length <= 1} - aria-label={language.t("provider.custom.headers.remove")} - /> -
- )} -
- -
- - -
-
- ) -} diff --git a/packages/app/src/components/dialog-release-notes.tsx b/packages/app/src/components/dialog-release-notes.tsx deleted file mode 100644 index d0a35b71beb2..000000000000 --- a/packages/app/src/components/dialog-release-notes.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { createSignal } from "solid-js" -import { Dialog } from "@opencode-ai/ui/dialog" -import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useLanguage } from "@/context/language" -import { useSettings } from "@/context/settings" - -export type Highlight = { - title: string - description: string - media?: { - type: "image" | "video" - src: string - alt?: string - } -} - -export function DialogReleaseNotes(props: { highlights: Highlight[] }) { - const dialog = useDialog() - const language = useLanguage() - const settings = useSettings() - const [index, setIndex] = createSignal(0) - - const total = () => props.highlights.length - const last = () => Math.max(0, total() - 1) - const feature = () => props.highlights[index()] ?? props.highlights[last()] - const isFirst = () => index() === 0 - const isLast = () => index() >= last() - const paged = () => total() > 1 - - function handleNext() { - if (isLast()) return - setIndex(index() + 1) - } - - function handleClose() { - dialog.close() - } - - function handleDisable() { - settings.general.setReleaseNotes(false) - handleClose() - } - - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - e.preventDefault() - handleClose() - return - } - - if (!paged()) return - if (e.key === "ArrowLeft" && !isFirst()) { - e.preventDefault() - setIndex(index() - 1) - } - if (e.key === "ArrowRight" && !isLast()) { - e.preventDefault() - setIndex(index() + 1) - } - } - - return ( - -
- {/* Left side - Text content */} -
- {/* Top section - feature content (fixed position from top) */} -
-
-

{feature()?.title ?? ""}

-
-

{feature()?.description ?? ""}

-
- - {/* Spacer to push buttons to bottom */} -
- - {/* Bottom section - buttons and indicators (fixed position) */} -
-
- {isLast() ? ( - - ) : ( - - )} - - -
- - {paged() && ( -
- {props.highlights.map((_, i) => ( - - ))} -
- )} -
-
- - {/* Right side - Media content (edge to edge) */} - {feature()?.media && ( -
- {feature()!.media!.type === "image" ? ( - {feature()!.media!.alt - ) : ( -
- )} -
-
- ) -} diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 08769068909c..b8e949058253 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -247,7 +247,7 @@ export function useServerManagementController(options: { onSelect?: () => void; mutationFn: async (value: string) => { const normalized = normalizeServerUrl(value) if (!normalized) { - resetAdd() + setStore("addServer", { error: language.t("dialog.server.localOnly") }) return } @@ -279,7 +279,7 @@ export function useServerManagementController(options: { onSelect?: () => void; if (input.original.type !== "http") return const normalized = normalizeServerUrl(input.value) if (!normalized) { - resetEdit() + setStore("editServer", { error: language.t("dialog.server.localOnly") }) return } @@ -511,7 +511,6 @@ export function useServerManagementController(options: { onSelect?: () => void; async function handleRemove(key: ServerConnection.Key) { try { - if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key) tabs.removeServer(key) server.remove(key) if ((await platform.getDefaultServer?.()) === key) { @@ -649,6 +648,7 @@ export function ServerConnectionList(props: { controller: ReturnType
+

{language.t("dialog.server.localOnly")}

- -
- )} - - - -
- -
-

{language.t("settings.providers.section.popular")}

- - - {(item) => ( -
-
-
- - {item.name} - - {language.t("dialog.provider.tag.recommended")} - - - {language.t("dialog.provider.tag.recommended")} - -
- - {(key) => {language.t(key())}} - +
+

{language.t("settings.providers.section.connected")}

+ + 0} + fallback={ +
{language.t("settings.providers.connected.empty")}
+ } + > + + {(provider) => ( +
+ +
+ {provider.name} + Authenticated local Copilot SDK bridge
-
)}
- -
-
-
- - {language.t("provider.custom.title")} - {language.t("settings.providers.tag.custom")} -
- - {language.t("settings.providers.custom.description")} - -
- -
-
- - -
+ +
) diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index ed2328c89aa5..2bac8447011f 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -438,7 +438,7 @@ export const SettingsGeneralV2: Component<{ description={ <> {language.t("settings.general.row.theme.description")}{" "} - + {language.t("common.learnMore")} diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index 981a13b5f716..349fe15f4c59 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -1,147 +1,23 @@ -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { Tag } from "@opencode-ai/ui/v2/badge-v2" -import { useDialog } from "@opencode-ai/ui/context/dialog" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { showToast } from "@/utils/toast" -import { popularProviders, useProviders } from "@/hooks/use-providers" import { createMemo, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" -import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" -import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider" -import { DialogCustomProvider } from "../dialog-custom-provider" +import { useProviders } from "@/hooks/use-providers" import { SettingsListV2 } from "./parts/list" import "./settings-v2.css" -type ProviderSource = "env" | "api" | "config" | "custom" -type ProviderItem = ReturnType["connected"]>[number] +const providerID = "github-copilot-sdk" +const providerIconSize = 16 -const PROVIDER_NOTES = [ - { match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" }, - { match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" }, - { match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" }, - { match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" }, - { match: (id: string) => id === "openai", key: "dialog.provider.openai.note" }, - { match: (id: string) => id === "google", key: "dialog.provider.google.note" }, - { match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" }, - { match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" }, -] as const - -const PROVIDER_ICON_SIZE = 16 - -export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => { - const dialog = useDialog() +export const SettingsProvidersV2: Component<{ onBack?: () => void }> = () => { const language = useLanguage() - const serverSdk = useServerSDK() - const serverSync = useServerSync() const providers = useProviders() - const providerConnect = useProviderConnectController({ onBack: props.onBack }) - - const connect = (provider?: string) => { - providerConnect.select(provider) - void dialog.show(() => ) - } - - const connected = createMemo(() => { - return providers - .connected() - .filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)) - }) - - const popular = createMemo(() => { - const connectedIDs = new Set(connected().map((p) => p.id)) - const items = providers - .popular() - .filter((p) => !connectedIDs.has(p.id)) - .slice() - items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)) - return items - }) - - const source = (item: ProviderItem): ProviderSource | undefined => { - if (!("source" in item)) return - const value = item.source - if (value === "env" || value === "api" || value === "config" || value === "custom") return value - return - } - - const type = (item: ProviderItem) => { - const current = source(item) - if (current === "env") return language.t("settings.providers.tag.environment") - if (current === "api") return language.t("provider.connect.method.apiKey") - if (current === "config") { - if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom") - return language.t("settings.providers.tag.config") - } - if (current === "custom") return language.t("settings.providers.tag.custom") - return language.t("settings.providers.tag.other") - } - - const canDisconnect = (item: ProviderItem) => source(item) !== "env" && item.id !== "github-copilot-sdk" - - const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key - - const isConfigCustom = (providerID: string) => { - const provider = serverSync().data.config.provider?.[providerID] - if (!provider) return false - if (provider.npm !== "@ai-sdk/openai-compatible") return false - if (!provider.models || Object.keys(provider.models).length === 0) return false - return true - } - - const disableProvider = async (providerID: string, name: string) => { - const before = serverSync().data.config.disabled_providers ?? [] - const next = before.includes(providerID) ? before : [...before, providerID] - serverSync().set("config", "disabled_providers", next) - - await serverSync() - .updateConfig({ disabled_providers: next }) - .then(() => { - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), - description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), - }) - }) - .catch((err: unknown) => { - serverSync().set("config", "disabled_providers", before) - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - }) - } - - const disconnect = async (providerID: string, name: string) => { - if (isConfigCustom(providerID)) { - await serverSdk() - .client.auth.remove({ providerID }) - .catch(() => undefined) - await disableProvider(providerID, name) - return - } - await serverSdk() - .client.auth.remove({ providerID }) - .then(async () => { - await serverSdk().client.global.dispose() - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), - description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), - }) - }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - }) - } + const connected = createMemo(() => providers.connected().filter((provider) => provider.id === providerID)) return ( <>

{language.t("settings.providers.title")}

-

{language.t("settings.providers.section.connected")}

@@ -153,104 +29,26 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) = } > - {(item) => ( -
+ {(provider) => ( +
-
- {item.name} - {type(item)} +
+ {provider.name} +

Authenticated local Copilot SDK bridge

- - {language.t("settings.providers.connected.environmentDescription")} - - } - > - void disconnect(item.id, item.name)}> - {language.t("common.disconnect")} - -
)}
- -
-

{language.t("settings.providers.section.popular")}

- - - {(item) => ( -
-
- -
-
- {item.name} - - {language.t("dialog.provider.tag.recommended")} - -
- - {(key) =>

{language.t(key())}

} -
-
-
- connect(item.id)}> - {language.t("common.connect")} - -
- )} -
- -
-
- -
-
- {language.t("provider.custom.title")} - {language.t("settings.providers.tag.custom")} -
-

{language.t("settings.providers.custom.description")}

-
-
- { - dialog.show(() => ) - }} - > - {language.t("common.connect")} - -
-
- - -
) diff --git a/packages/app/src/components/settings-v2/servers.tsx b/packages/app/src/components/settings-v2/servers.tsx index b93f700d24cf..f25828d6151c 100644 --- a/packages/app/src/components/settings-v2/servers.tsx +++ b/packages/app/src/components/settings-v2/servers.tsx @@ -1,4 +1,5 @@ import { Tag } from "@opencode-ai/ui/v2/badge-v2" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" @@ -13,7 +14,6 @@ import { ServerConnection, serverName } from "@/context/server" import { useServerManagementController } from "../dialog-select-server" import { DialogServerV2 } from "./dialog-server-v2" import { SettingsListV2 } from "./parts/list" -import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings" import "./settings-v2.css" export const SettingsServersV2: Component = () => { @@ -21,14 +21,10 @@ export const SettingsServersV2: Component = () => { const language = useLanguage() const controller = useServerManagementController() const [store, setStore] = createStore({ filter: "" }) - const wslServers = useFilteredWslServers(() => store.filter) - - const showSearch = createMemo( - () => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1, - ) + const showSearch = createMemo(() => controller.sortedItems().length > 1) const filtered = createMemo(() => { - const items = controller.sortedItems().filter((item) => !isWslServer(item)) + const items = controller.sortedItems() const query = store.filter.trim() if (!query) return items return fuzzysort @@ -54,7 +50,9 @@ export const SettingsServersV2: Component = () => { >

{language.t("status.popover.tab.servers")}

- + + {language.t("dialog.server.add.button")} +