Compare commits

..

3 Commits

Author SHA1 Message Date
vprdev 92e5af4fa0 fix(console): complete Go login readiness 2026-08-03 12:19:31 -04:00
vprdev 86e4631993 feat(console): connect named go subscribers 2026-08-03 11:56:34 -04:00
vprdev d7ee68a41d fix(console): route go links to console 2026-08-03 11:23:44 -04:00
184 changed files with 2712 additions and 4752 deletions
+2 -16
View File
@@ -143,10 +143,7 @@
"open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"uqr": "0.1.3",
"web-tree-sitter": "0.25.10",
"ws": "8.21.0",
},
"devDependencies": {
@@ -405,11 +402,8 @@
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:",
},
@@ -1079,11 +1073,9 @@
},
"trustedDependencies": [
"esbuild",
"tree-sitter-powershell",
"protobufjs",
"electron",
"web-tree-sitter",
"tree-sitter-bash",
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
@@ -4080,10 +4072,10 @@
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -5642,10 +5634,6 @@
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
"treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
@@ -6868,8 +6856,6 @@
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="],
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
-2
View File
@@ -12,8 +12,6 @@
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
## Tests
- Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers.
+4 -58
View File
@@ -29,30 +29,9 @@ export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1bet
export interface OptionsInput {
readonly [key: string]: unknown
readonly cachedContent?: string
readonly safetySettings?: ReadonlyArray<{
readonly category:
| "HARM_CATEGORY_UNSPECIFIED"
| "HARM_CATEGORY_HATE_SPEECH"
| "HARM_CATEGORY_DANGEROUS_CONTENT"
| "HARM_CATEGORY_HARASSMENT"
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
| "HARM_CATEGORY_CIVIC_INTEGRITY"
| (string & {})
readonly threshold:
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
| "BLOCK_LOW_AND_ABOVE"
| "BLOCK_MEDIUM_AND_ABOVE"
| "BLOCK_ONLY_HIGH"
| "BLOCK_NONE"
| "OFF"
| (string & {})
}>
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
readonly thinkingConfig?: {
readonly thinkingBudget?: number
readonly includeThoughts?: boolean
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
}
}
@@ -132,12 +111,6 @@ const GeminiToolConfig = Schema.Struct({
const GeminiThinkingConfig = Schema.Struct({
thinkingBudget: Schema.optional(Schema.Number),
includeThoughts: Schema.optional(Schema.Boolean),
thinkingLevel: Schema.optional(Schema.String),
})
const GeminiSafetySetting = Schema.Struct({
category: Schema.String,
threshold: Schema.String,
})
const GeminiGenerationConfig = Schema.Struct({
@@ -150,10 +123,7 @@ const GeminiGenerationConfig = Schema.Struct({
})
const GeminiBodyFields = {
cachedContent: Schema.optional(Schema.String),
contents: Schema.Array(GeminiContent),
safetySettings: optionalArray(GeminiSafetySetting),
serviceTier: Schema.optional(Schema.String),
systemInstruction: Schema.optional(GeminiSystemInstruction),
tools: optionalArray(GeminiTool),
toolConfig: Schema.optional(GeminiToolConfig),
@@ -346,38 +316,17 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
})
const resolveOptions = (request: LLMRequest) => {
const input = request.providerOptions?.gemini
const value = input?.thinkingConfig
const value = request.providerOptions?.gemini?.thinkingConfig
if (!ProviderShared.isRecord(value)) return {}
const thinkingConfig = {
thinkingBudget:
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
includeThoughts:
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
? value.includeThoughts
: ProviderShared.isRecord(value)
? true
: undefined,
thinkingLevel:
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
}
return {
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
safetySettings: mapSafetySettings(input?.safetySettings),
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
}
}
function mapSafetySettings(value: unknown) {
if (!Array.isArray(value)) return undefined
const settings = value.flatMap((item) =>
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
? [{ category: item.category, threshold: item.threshold }]
: [],
)
return settings
}
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const hasTools = request.tools.length > 0
const generation = request.generation
@@ -393,10 +342,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
}
return {
cachedContent: options.cachedContent,
contents: yield* lowerMessages(request),
safetySettings: options.safetySettings,
serviceTier: options.serviceTier,
systemInstruction:
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
tools: hasTools
-1
View File
@@ -1,7 +1,6 @@
import type { Model, ProviderOptions } from "./schema"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
+1 -19
View File
@@ -5,7 +5,6 @@ import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import type { ProviderPackage } from "../provider-package"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAIChat from "../protocols/openai-chat"
import { isRecord } from "../protocols/shared"
@@ -31,12 +30,6 @@ export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenRouterProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput
}
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
Schema.Record(Schema.String, Schema.Any),
])
@@ -120,15 +113,4 @@ export const configure = (input: ModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
modelID,
settings,
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
export const model = provider.model
+12 -50
View File
@@ -1,62 +1,31 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { Route, type RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } from "../schema"
import type { RouteDefaultsInput } from "../route/client"
import { HttpOptions, ProviderID, type ModelID } from "../schema"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { XAIImages } from "../protocols/xai-images"
import type { OpenAIOptionsInput } from "./openai-options"
import type { ProviderPackage } from "../provider-package"
import type { OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("xai")
export type XAIProviderOptionsInput = ProviderOptions & {
readonly xai?: OpenAIOptionsInput
}
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: XAIProviderOptionsInput
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: XAIProviderOptionsInput
}
export type { XAIImageOptions } from "../protocols/xai-images"
const responsesRoute = Route.make({
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAIResponses.httpTransport,
defaults: { providerOptions: { xai: { store: false } } },
})
const chatRoute = Route.make({
id: "openai-compatible-chat",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAICompatibleChat.route.transport,
})
export const routes = [responsesRoute, chatRoute]
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
const configuredResponsesRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return responsesRoute.with({
return OpenAIResponses.route.with({
...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
@@ -64,8 +33,9 @@ const configuredResponsesRoute = (input: ModelOptions) => {
const configuredChatRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return chatRoute.with({
return OpenAICompatibleChat.route.with({
...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
@@ -74,8 +44,8 @@ const configuredChatRoute = (input: ModelOptions) => {
export const configure = (input: ModelOptions = {}) => {
const responsesRoute = configuredResponsesRoute(input)
const chatRoute = configuredChatRoute(input)
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID })
const responses = (modelID: string | ModelID) => responsesRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
const image = (modelID: string | ModelID) =>
XAIImages.model({
id: modelID,
@@ -95,15 +65,7 @@ export const configure = (input: ModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
export const model = provider.model
export const responses = provider.responses
export const chat = provider.chat
export const image = provider.image
+17 -26
View File
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor"
import { Framing } from "./framing"
import { HttpTransport } from "./transport"
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
import type { Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
@@ -46,11 +46,7 @@ export interface Route<Body, Prepared = unknown> {
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
readonly model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) => Model<Options>
readonly prepareTransport: (
body: Body,
request: LLMRequest,
options?: StreamOptions,
) => Effect.Effect<Prepared, LLMError>
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
readonly streamPrepared: (
prepared: Prepared,
request: LLMRequest,
@@ -149,16 +145,12 @@ export interface Interface {
readonly generate: GenerateMethod
}
export interface StreamOptions {
readonly transform?: HttpRequestTransform
}
export interface StreamMethod {
(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError>
(request: LLMRequest): Stream.Stream<LLMEvent, LLMError>
}
export interface GenerateMethod {
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError>
(request: LLMRequest): Effect.Effect<LLMResponse, LLMError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
@@ -294,7 +286,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
},
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) =>
makeRouteModel<Options>(route, input),
prepareTransport: (body, request, options) =>
prepareTransport: (body, request) =>
routeInput.transport.prepare({
body,
request,
@@ -302,7 +294,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
transform: options?.transform,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}`
@@ -368,14 +359,14 @@ export function make<Body, Prepared, Frame, Event, State>(
})
}
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = resolved.model.route
const body = yield* route.body
.from(resolved)
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
const prepared = yield* route.prepareTransport(body, resolved, options)
const prepared = yield* route.prepareTransport(body, resolved)
return {
request: resolved,
@@ -398,17 +389,17 @@ export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request
}
})
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: StreamOptions) =>
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
Stream.unwrap(
Effect.gen(function* () {
const compiled = yield* compile(request, options)
const compiled = yield* compile(request)
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
}),
)
const generateWith = (stream: Interface["stream"]) =>
Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: StreamOptions) {
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
const response = LLMResponse.complete(state)
if (response) return response
return yield* ProviderShared.eventError(
@@ -417,24 +408,24 @@ const generateWith = (stream: Interface["stream"]) =>
)
})
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError> {
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
return Stream.unwrap(
Effect.gen(function* () {
return (yield* Service).stream(request, options)
return (yield* Service).stream(request)
}),
) as Stream.Stream<LLMEvent, LLMError>
}
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError> {
export function generate(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> {
return Effect.gen(function* () {
return yield* (yield* Service).generate(request, options)
return yield* (yield* Service).generate(request)
}) as Effect.Effect<LLMResponse, LLMError>
}
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
export const streamRequest = (request: LLMRequest) =>
Stream.unwrap(
Effect.gen(function* () {
return (yield* Service).stream(request, options)
return (yield* Service).stream(request)
}),
)
+1 -2
View File
@@ -8,7 +8,6 @@ export type {
AnyRoute,
Interface as LLMClientShape,
Service as LLMClientService,
StreamOptions,
} from "./client"
export * from "./executor"
export { Auth } from "./auth"
@@ -23,4 +22,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol"
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
export type { Transport as TransportDef, TransportRuntime } from "./transport"
+7 -12
View File
@@ -120,19 +120,14 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
id: "http-json",
with: (patch) => httpJson({ ...input, ...patch }),
prepare: (prepareInput) =>
Effect.gen(function* () {
const parts = yield* jsonRequestParts({ ...prepareInput })
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
yield* (prepareInput.transform?.(request) ?? Effect.void)
return {
request: ProviderShared.jsonPost({
url: request.url,
body: request.body ?? "",
headers: Headers.fromInput(request.headers),
}),
jsonRequestParts({
...prepareInput,
}).pipe(
Effect.map((parts) => ({
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
framing: input.framing,
}
}),
})),
),
frames: (prepared, request, runtime) =>
Stream.unwrap(
runtime.http
-10
View File
@@ -10,15 +10,6 @@ export interface TransportRuntime {
readonly webSocket?: WebSocketExecutorInterface
}
export interface HttpRequest {
url: string
readonly method: string
headers: Record<string, string>
body: string | undefined
}
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
export interface Transport<Body, Prepared, Frame> {
readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
@@ -36,7 +27,6 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transform?: HttpRequestTransform
}
export * as HttpTransport from "./http"
-34
View File
@@ -137,40 +137,6 @@ describe("request option precedence", () => {
),
)
it.effect("transforms the final HTTP request after serialization and authentication", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("fresh-key") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
}),
{
transform: (request) =>
Effect.sync(() => {
expect(request.headers.authorization).toBe("Bearer fresh-key")
request.url = "https://proxy.test/v1/chat/completions"
request.headers["x-plugin"] = "transformed"
request.body = JSON.stringify({ transformed: true })
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
expect(web.headers.get("x-plugin")).toBe("transformed")
expect(decodeJson(input.text)).toEqual({ transformed: true })
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("rejects raw body overlays for protocol-owned roots", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
+2
View File
@@ -53,7 +53,9 @@ describe("public exports", () => {
expect(CloudflareWorkersAI.configure).toBeFunction()
expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction()
expect(OpenRouter.model).toBeFunction()
expect(OpenRouter.provider.model).toBe(OpenRouter.model)
expect(XAI.model).toBeFunction()
expect(XAI.provider.model).toBe(XAI.model)
expect(XAI.provider.responses).toBe(XAI.responses)
expect(XAI.provider.chat).toBe(XAI.chat)
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
@@ -9,39 +9,9 @@ LLM.request({
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1024 } } },
})
LLM.request({
model,
prompt: "Hello",
providerOptions: {
gemini: {
// @ts-expect-error Gemini safety settings require a threshold for every category.
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH" }],
},
},
})
LLM.request({
model,
prompt: "Hello",
providerOptions: {
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
serviceTier: "future-tier",
thinkingConfig: { thinkingLevel: "high", includeThoughts: true },
},
},
})
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Gemini thinking budgets must be numeric.
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } },
})
LLM.request({
model,
prompt: "Hello",
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "maximum" } } },
})
@@ -3,11 +3,11 @@ import { XAI } from "../../src/providers"
const model = XAI.provider.model("grok-4")
LLM.request({ model, prompt: "Hello", providerOptions: { xai: { reasoningEffort: "high" } } })
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
providerOptions: { xai: { reasoningEffort: true } },
providerOptions: { openai: { reasoningEffort: true } },
})
-31
View File
@@ -21,8 +21,6 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/google-vertex/chat"),
import("@opencode-ai/ai/providers/google-vertex/responses"),
import("@opencode-ai/ai/providers/google-vertex/messages"),
import("@opencode-ai/ai/providers/openrouter"),
import("@opencode-ai/ai/providers/xai"),
])
for (const module of modules) expect(module.model).toBeFunction()
@@ -31,35 +29,6 @@ describe("provider package entrypoints", () => {
expect(modules[12].model).toBe(modules[13].model)
})
test("maps OpenRouter and xAI package settings onto executable models", async () => {
const OpenRouter = await import("@opencode-ai/ai/providers/openrouter")
const XAI = await import("@opencode-ai/ai/providers/xai")
const settings = {
apiKey: "fixture",
baseURL: "https://provider.example.test/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
}
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
...settings,
providerOptions: { openrouter: { usage: true } },
})
const xai = XAI.model("grok-4", {
...settings,
providerOptions: { xai: { reasoningEffort: "high" } },
})
for (const selected of [openrouter, xai]) {
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
expect(selected.route.defaults.headers).toEqual(settings.headers)
expect(selected.route.defaults.http?.body).toEqual(settings.body)
expect(selected.route.defaults.limits).toEqual(settings.limits)
}
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } })
expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } })
})
test("maps package settings onto the executable model", () => {
const selected = model("gpt-5", {
apiKey: "fixture",
+1 -29
View File
@@ -41,14 +41,7 @@ describe("Gemini route", () => {
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
serviceTier: "priority",
thinkingConfig: { thinkingBudget: 0, includeThoughts: false, thinkingLevel: "high" },
},
},
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
}),
)
const filtered = yield* compileRequest(
@@ -56,33 +49,12 @@ describe("Gemini route", () => {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
}),
)
const defaulted = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "high" } } },
}),
)
const emptySafetySettings = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { gemini: { safetySettings: [] } },
}),
)
expect(prepared.body.generationConfig?.thinkingConfig).toEqual({
thinkingBudget: 0,
includeThoughts: false,
thinkingLevel: "high",
})
expect(prepared.body.cachedContent).toBe("cachedContents/example")
expect(prepared.body.safetySettings).toEqual([
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" },
])
expect(prepared.body.serviceTier).toBe("priority")
expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false })
expect(defaulted.body.generationConfig?.thinkingConfig).toEqual({
includeThoughts: true,
thinkingLevel: "high",
})
expect(emptySafetySettings.body.safetySettings).toEqual([])
}),
)
@@ -11,7 +11,6 @@ import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover"
import { sessionTitle } from "@/utils/session-title"
import "./titlebar-tab-nav.css"
// MouseEvent.button uses 1 for the middle/wheel button.
@@ -55,10 +54,7 @@ export function TabNavItem(props: {
if (!session) return
return projectForSession(session, serverCtx()?.projects.list() ?? [])
})
const title = createMemo(() => {
const session = props.session()
return session ? sessionTitle(session.title, session.parentID) : props.fallbackTitle
})
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
const projectName = createMemo(() => {
const session = props.session()
@@ -147,7 +143,7 @@ export function TabNavItem(props: {
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
const session = props.session()
if (!session) return
titleEl.textContent = session.title ?? ""
titleEl.textContent = session.title
setEditing(true)
requestAnimationFrame(() => {
@@ -306,7 +302,7 @@ export function TabNavItem(props: {
}}
data={{
projectName: projectName(),
title: title(),
title: props.session()?.title,
path: previewPath(),
serverName: serverLabel(),
}}
@@ -7,7 +7,6 @@ import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller"
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller"
import { sessionTitle } from "@/utils/session-title"
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
@@ -24,7 +23,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
if (!value) return []
return sessions.data
.searchRecords()
.filter((record) => `${sessionTitle(record.session.title)} ${record.projectName}`.toLowerCase().includes(value))
.filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
})
const active = createMemo(() => {
const records = results()
@@ -104,7 +104,7 @@ const SessionRow = (props: {
warmPress: () => void
warmFocus: () => void
}): JSX.Element => {
const title = () => sessionTitle(props.session.title, props.session.parentID)
const title = () => sessionTitle(props.session.title)
return (
<A
@@ -229,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={sessionTitle(props.session.title, props.session.parentID)}
value={sessionTitle(props.session.title)}
gutter={10}
class="min-w-0 w-full"
>
@@ -297,11 +297,7 @@ export function MessageTimeline(props: {
return sync().session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => {
const session = info()
if (!session) return
return sessionTitle(titleValue(), session.parentID)
})
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
@@ -315,10 +311,7 @@ export function MessageTimeline(props: {
if (!id) return emptyMessages
return sync().data.message[id] ?? emptyMessages
})
const parentTitle = createMemo(() => {
const session = parent()
return session ? sessionTitle(session.title, session.parentID) : language.t("command.session.new")
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
const childTaskDescription = createMemo(() => {
@@ -336,7 +329,7 @@ export function MessageTimeline(props: {
if (value) return value
return language.t("command.session.new")
})
const showHeader = createMemo(() => !!(titleLabel() || parentID()))
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
@@ -919,10 +912,9 @@ export function MessageTimeline(props: {
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(() => {
const session = sync().session.get(props.sessionID)
return session ? sessionTitle(session.title, session.parentID) : language.t("command.session.new")
})
const name = createMemo(
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
@@ -1,11 +0,0 @@
import { describe, expect, test } from "bun:test"
import { sessionTitle } from "./session-title"
describe("sessionTitle", () => {
test("uses a display fallback without persisting it", () => {
expect(sessionTitle(undefined)).toBe("New session")
expect(sessionTitle(undefined, "ses_parent")).toBe("Child session")
expect(sessionTitle("New session - 2026-07-30T18:45:03.662Z")).toBe("New session")
expect(sessionTitle("Generated title")).toBe("Generated title")
})
})
+2 -2
View File
@@ -1,7 +1,7 @@
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
export function sessionTitle(title?: string, parentID?: string) {
if (!title) return parentID ? "Child session" : "New session"
export function sessionTitle(title?: string) {
if (!title) return title
const match = title.match(pattern)
return match?.[1] ?? title
}
+1 -1
View File
@@ -13,7 +13,7 @@ export function normalizeSessionInfo(input: SessionInfo | Session): Session {
parentID: input.parentID,
cost: input.cost,
tokens: input.tokens,
title: input.title ?? `${input.parentID ? "Child" : "New"} session - ${new Date(input.time.created).toISOString()}`,
title: input.title,
agent: input.agent,
model: input.model,
version: "",
-3
View File
@@ -40,9 +40,6 @@
"open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"web-tree-sitter": "0.25.10",
"uqr": "0.1.3",
"ws": "8.21.0"
},
+1 -1
View File
@@ -62,8 +62,8 @@ for (const target of targets) {
await rm("dist-node", { recursive: true, force: true })
const assetHash = await hashNodeAssets(assets)
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
await copyNodeAssets(assets)
await build(mainConfig(input))
await copyNodeAssets(assets)
const host = target.platform === process.platform && target.arch === process.arch
if (host) {
+1 -5
View File
@@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
const dir = path.resolve(import.meta.dirname, "..")
@@ -43,10 +43,6 @@ export async function collectNodeAssets(target: NodeTarget) {
key: photonWasmAsset,
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
},
...Object.values(shellParserWasmAssets).map((key) => ({
key,
source: fileURLToPath(import.meta.resolve(key)),
})),
...attentionSoundAssets.map((key) => ({
key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
+1 -3
View File
@@ -213,9 +213,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
sessions: page.data.map((session) => ({
sessionId: session.id,
cwd: session.location.directory,
title:
session.title ??
`${session.parentID ? "Child" : "New"} session - ${new Date(session.time.created).toISOString()}`,
title: session.title,
updatedAt: new Date(session.time.updated).toISOString(),
})),
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
-5
View File
@@ -29,11 +29,6 @@ export function nodeTarget(platform: string, arch: string) {
}
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
export const shellParserWasmAssets = {
runtime: "web-tree-sitter/tree-sitter.wasm",
bash: "tree-sitter-bash/tree-sitter-bash.wasm",
powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm",
} as const
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
export const attentionSoundAssets = [
+1 -10
View File
@@ -3,13 +3,7 @@ import { readFile } from "node:fs/promises"
import { createRequire } from "node:module"
import { defineConfig, type Plugin, type UserConfig } from "vite"
import solid from "vite-plugin-solid"
import {
nodeExecArgv,
nodeTarget,
type NodeTarget,
photonWasmAsset,
shellParserWasmAssets,
} from "./src/node/target"
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
const dir = import.meta.dirname
@@ -200,9 +194,6 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)})
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)})
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
try {
@@ -1757,7 +1757,7 @@ export type SessionInfo = {
cost: MoneyUSD
tokens: TokenUsageInfo
time: { created: number; updated: number; archived?: number }
title?: string
title: string
location: LocationRef
subpath?: string
revert?: SessionRevert
@@ -2006,7 +2006,7 @@ export type SessionV1Info = {
cost?: number
tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
share?: { url: string }
title?: string
title: string
agent?: string
model?: { id: string; providerID: string; variant?: string }
version: string
+12 -14
View File
@@ -264,8 +264,7 @@ export const dict = {
"go.cta.text": "اشترك في Go",
"go.cta.price": "$10/شهر",
"go.cta.promo": "$5 للشهر الأول",
"go.pricing.body":
"استخدمه مع أي وكيل. $5 للشهر الأول، ثم $10/شهر. قم بزيادة الرصيد إذا لزم الأمر. الإلغاء في أي وقت.",
"go.pricing.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر.",
"go.graph.free": "مجاني",
"go.graph.freePill": "Big Pickle ونماذج مجانية",
"go.graph.go": "Go",
@@ -304,15 +303,15 @@ export const dict = {
"go.problem.item4":
"يتضمن Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3",
"go.how.title": "كيف يعمل Go",
"go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.",
"go.how.step1.title": "أنشئ حسابًا",
"go.how.step1.beforeLink": "اتبع",
"go.how.step1.link": "تعليمات الإعداد",
"go.how.step2.title": "اشترك في Go",
"go.how.step2.link": "$5 للشهر الأول",
"go.how.step2.afterLink": "ثم $10/شهر مع حدود سخية",
"go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر.",
"go.how.step1.title": "اشترك في Go",
"go.how.step1.beforeLink": "في",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "ربط OpenCode",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "ووافق على الجهاز في متصفحك",
"go.how.step3.title": "ابدأ البرمجة",
"go.how.step3.body": "مع وصول موثوق لنماذج مفتوحة المصدر",
"go.how.step3.body": "مع مزود opencode",
"go.privacy.title": "خصوصيتك مهمة بالنسبة لنا",
"go.privacy.body":
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر.",
@@ -344,8 +343,8 @@ export const dict = {
"go.faq.a6": "إذا كنت بحاجة إلى مزيد من الاستخدام، يمكنك شحن رصيد في حسابك.",
"go.faq.q7": "هل يمكنني الإلغاء؟",
"go.faq.a7": "نعم، يمكنك الإلغاء في أي وقت.",
"go.faq.q8": "هل يمكنني استخدام Go مع وكلاء برمجة آخرين؟",
"go.faq.a8": "نعم، يمكنك استخدام Go مع أي وكيل. اتبع تعليمات الإعداد في وكيل البرمجة المفضل لديك.",
"go.faq.q8": "ما الوصول المؤجل؟",
"go.faq.a8": "دعم الوكلاء الخارجيين وحسابات الخدمة مؤجل.",
"go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟",
"go.faq.a9":
@@ -650,8 +649,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "الاستخدام الشهري",
"workspace.lite.subscription.resetsIn": "إعادة تعيين في",
"workspace.lite.subscription.useBalance": "استخدم رصيدك المتوفر بعد الوصول إلى حدود الاستخدام",
"workspace.lite.subscription.selectProvider":
'اختر "OpenCode Go" كمزود في إعدادات opencode الخاصة بك لاستخدام نماذج Go.',
"workspace.lite.subscription.selectProvider": "اختر مزود opencode لاستخدام نماذج Go.",
"workspace.lite.providers.title": "المزودون",
"workspace.lite.providers.description": "تحكم في المزودين المستخدمين للتوجيه.",
"workspace.lite.providers.useChina": "تفعيل النماذج المستضافة في الصين",
+12 -16
View File
@@ -268,8 +268,7 @@ export const dict = {
"go.cta.text": "Assinar o Go",
"go.cta.price": "$10/mês",
"go.cta.promo": "$5 no primeiro mês",
"go.pricing.body":
"Use com qualquer agente. $5 no primeiro mês, depois $10/mês. Recarregue o crédito se necessário. Cancele a qualquer momento.",
"go.pricing.body": "O Go começa em $5 no primeiro mês, depois $10/mês.",
"go.graph.free": "Grátis",
"go.graph.freePill": "Big Pickle e modelos gratuitos",
"go.graph.go": "Go",
@@ -309,16 +308,15 @@ export const dict = {
"go.problem.item4":
"Inclui Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3",
"go.how.title": "Como o Go funciona",
"go.how.body":
"O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.",
"go.how.step1.title": "Crie uma conta",
"go.how.step1.beforeLink": "siga as",
"go.how.step1.link": "instruções de configuração",
"go.how.step2.title": "Assinar o Go",
"go.how.step2.link": "$5 no primeiro mês",
"go.how.step2.afterLink": "depois $10/mês com limites generosos",
"go.how.body": "O Go começa em $5 no primeiro mês, depois $10/mês.",
"go.how.step1.title": "Assinar o Go",
"go.how.step1.beforeLink": "no",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "Conectar o OpenCode",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "e aprove o dispositivo no navegador",
"go.how.step3.title": "Comece a codificar",
"go.how.step3.body": "com acesso confiável a modelos de código aberto",
"go.how.step3.body": "com o provedor opencode",
"go.privacy.title": "Sua privacidade é importante para nós",
"go.privacy.body":
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável.",
@@ -351,9 +349,8 @@ export const dict = {
"go.faq.a6": "Se você precisar de mais uso, pode recarregar crédito em sua conta.",
"go.faq.q7": "Posso cancelar?",
"go.faq.a7": "Sim, você pode cancelar a qualquer momento.",
"go.faq.q8": "Posso usar o Go com outros agentes de codificação?",
"go.faq.a8":
"Sim, você pode usar o Go com qualquer agente. Siga as instruções de configuração no seu agente de codificação preferido.",
"go.faq.q8": "Qual acesso foi adiado?",
"go.faq.a8": "O suporte a agentes externos e contas de serviço foi adiado.",
"go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?",
"go.faq.a9":
@@ -660,8 +657,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "Uso Mensal",
"workspace.lite.subscription.resetsIn": "Reinicia em",
"workspace.lite.subscription.useBalance": "Use seu saldo disponível após atingir os limites de uso",
"workspace.lite.subscription.selectProvider":
'Selecione "OpenCode Go" como provedor na sua configuração do opencode para usar os modelos Go.',
"workspace.lite.subscription.selectProvider": "Selecione o provedor opencode para usar os modelos Go.",
"workspace.lite.providers.title": "Provedores",
"workspace.lite.providers.description": "Controle quais provedores são usados para roteamento.",
"workspace.lite.providers.useChina": "Ativar modelos hospedados na China",
+12 -15
View File
@@ -266,8 +266,7 @@ export const dict = {
"go.cta.text": "Abonner på Go",
"go.cta.price": "$10/måned",
"go.cta.promo": "$5 første måned",
"go.pricing.body":
"Brug med enhver agent. $5 første måned, derefter $10/måned. Tank op med kredit efter behov. Afmeld når som helst.",
"go.pricing.body": "Go starter ved $5 for den første måned, derefter $10/måned.",
"go.graph.free": "Gratis",
"go.graph.freePill": "Big Pickle og gratis modeller",
"go.graph.go": "Go",
@@ -306,16 +305,15 @@ export const dict = {
"go.problem.item4":
"Inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3",
"go.how.title": "Hvordan Go virker",
"go.how.body":
"Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.",
"go.how.step1.title": "Opret en konto",
"go.how.step1.beforeLink": "følg",
"go.how.step1.link": "opsætningsinstruktionerne",
"go.how.step2.title": "Abonner på Go",
"go.how.step2.link": "$5 første måned",
"go.how.step2.afterLink": "derefter $10/måned med generøse grænser",
"go.how.body": "Go starter ved $5 for den første måned, derefter $10/måned.",
"go.how.step1.title": "Abonner på Go",
"go.how.step1.beforeLink": "i",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "Forbind OpenCode",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "og godkend enheden i din browser",
"go.how.step3.title": "Start kodning",
"go.how.step3.body": "med pålidelig adgang til open source-modeller",
"go.how.step3.body": "med opencode-udbyderen",
"go.privacy.title": "Dit privatliv er vigtigt for os",
"go.privacy.body":
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang.",
@@ -348,8 +346,8 @@ export const dict = {
"go.faq.a6": "Hvis du har brug for mere forbrug, kan du tanke kredit op på din konto.",
"go.faq.q7": "Kan jeg annullere?",
"go.faq.a7": "Ja, du kan annullere til enhver tid.",
"go.faq.q8": "Kan jeg bruge Go med andre kodningsagenter?",
"go.faq.a8": "Ja, du kan bruge Go med enhver agent. Følg opsætningsinstruktionerne i din foretrukne kodningsagent.",
"go.faq.q8": "Hvilken adgang er udskudt?",
"go.faq.a8": "Understøttelse af eksterne agenter og tjenestekonti er udskudt.",
"go.faq.q9": "Hvad er forskellen på gratis modeller og Go?",
"go.faq.a9":
@@ -656,8 +654,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "Månedligt forbrug",
"workspace.lite.subscription.resetsIn": "Nulstiller i",
"workspace.lite.subscription.useBalance": "Brug din tilgængelige saldo, når du har nået forbrugsgrænserne",
"workspace.lite.subscription.selectProvider":
'Vælg "OpenCode Go" som udbyder i din opencode-konfiguration for at bruge Go-modeller.',
"workspace.lite.subscription.selectProvider": "Vælg opencode-udbyderen for at bruge Go-modeller.",
"workspace.lite.providers.title": "Udbydere",
"workspace.lite.providers.description": "Styr, hvilke udbydere der bruges til routing.",
"workspace.lite.providers.useChina": "Aktivér modeller hostet i Kina",
+12 -16
View File
@@ -268,8 +268,7 @@ export const dict = {
"go.cta.text": "Go abonnieren",
"go.cta.price": "$10/Monat",
"go.cta.promo": "$5 im ersten Monat",
"go.pricing.body":
"Mit jedem Agenten nutzbar. $5 im ersten Monat, danach $10/Monat. Guthaben bei Bedarf aufladen. Jederzeit kündbar.",
"go.pricing.body": "Go beginnt bei $5 für den ersten Monat, danach $10/Monat.",
"go.graph.free": "Kostenlos",
"go.graph.freePill": "Big Pickle und kostenlose Modelle",
"go.graph.go": "Go",
@@ -308,16 +307,15 @@ export const dict = {
"go.problem.item4":
"Beinhaltet Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3",
"go.how.title": "Wie Go funktioniert",
"go.how.body":
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.",
"go.how.step1.title": "Konto erstellen",
"go.how.step1.beforeLink": "folge den",
"go.how.step1.link": "Einrichtungsanweisungen",
"go.how.step2.title": "Go abonnieren",
"go.how.step2.link": "$5 im ersten Monat",
"go.how.step2.afterLink": "danach $10/Monat mit großzügigen Limits",
"go.how.body": "Go beginnt bei $5 für den ersten Monat, danach $10/Monat.",
"go.how.step1.title": "Go abonnieren",
"go.how.step1.beforeLink": "in",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "OpenCode verbinden",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "und autorisiere das Gerät in deinem Browser",
"go.how.step3.title": "Loslegen mit Coding",
"go.how.step3.body": "mit zuverlässigem Zugang zu Open-Source-Modellen",
"go.how.step3.body": "mit dem opencode-Anbieter",
"go.privacy.title": "Deine Privatsphäre ist uns wichtig",
"go.privacy.body":
"Der Plan ist primär für internationale Nutzer konzipiert, mit Modellen gehostet in den USA, der EU und Singapur für stabilen globalen Zugang.",
@@ -350,9 +348,8 @@ export const dict = {
"go.faq.a6": "Wenn du mehr Nutzung benötigst, kannst du Guthaben in deinem Konto aufladen.",
"go.faq.q7": "Kann ich kündigen?",
"go.faq.a7": "Ja, du kannst jederzeit kündigen.",
"go.faq.q8": "Kann ich Go mit anderen Coding-Agenten nutzen?",
"go.faq.a8":
"Ja, du kannst Go mit jedem Agenten nutzen. Folge den Einrichtungsanweisungen in deinem bevorzugten Coding-Agenten.",
"go.faq.q8": "Welcher Zugriff ist zurückgestellt?",
"go.faq.a8": "Unterstützung für externe Agenten und Dienstkonten ist zurückgestellt.",
"go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?",
"go.faq.a9":
@@ -659,8 +656,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "Monatliche Nutzung",
"workspace.lite.subscription.resetsIn": "Setzt zurück in",
"workspace.lite.subscription.useBalance": "Nutze dein verfügbares Guthaben, nachdem die Nutzungslimits erreicht sind",
"workspace.lite.subscription.selectProvider":
'Wähle "OpenCode Go" als Anbieter in deiner opencode-Konfiguration, um Go-Modelle zu verwenden.',
"workspace.lite.subscription.selectProvider": "Wähle den opencode-Anbieter, um Go-Modelle zu verwenden.",
"workspace.lite.providers.title": "Anbieter",
"workspace.lite.providers.description": "Steuere, welche Anbieter für das Routing verwendet werden.",
"workspace.lite.providers.useChina": "In China gehostete Modelle aktivieren",
+14 -13
View File
@@ -265,7 +265,8 @@ export const dict = {
"go.cta.text": "Subscribe to Go",
"go.cta.price": "$10/month",
"go.cta.promo": "$5 first month",
"go.pricing.body": "Use with any agent. $5 first month, then $10/month. Top up credit if needed. Cancel any time.",
"go.pricing.body":
"For a named OpenCode subscriber. $5 first month, then $10/month. Service accounts are not eligible. Cancel any time.",
"go.graph.free": "Free",
"go.graph.freePill": "Big Pickle and free models",
"go.graph.go": "Go",
@@ -304,15 +305,15 @@ export const dict = {
"go.problem.item4":
"Includes Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3",
"go.how.title": "How Go works",
"go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.",
"go.how.step1.title": "Create an account",
"go.how.step1.beforeLink": "follow the",
"go.how.step1.link": "setup instructions",
"go.how.step2.title": "Subscribe to Go",
"go.how.step2.link": "$5 first month",
"go.how.step2.afterLink": "then $10/month with generous limits",
"go.how.body": "Go is available to a named subscriber using OpenCode. No API key needs to be copied.",
"go.how.step1.title": "Subscribe to Go",
"go.how.step1.beforeLink": "in",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "Connect OpenCode",
"go.how.step2.link": "run opencode2 console login",
"go.how.step2.afterLink": "and authorize the device in your browser",
"go.how.step3.title": "Start coding",
"go.how.step3.body": "with reliable access to open-source models",
"go.how.step3.body": "with the opencode provider",
"go.privacy.title": "Your privacy is important to us",
"go.privacy.body":
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access.",
@@ -346,8 +347,9 @@ export const dict = {
"go.faq.a6": "If you need more usage, you can top up credit in your account.",
"go.faq.q7": "Can I cancel?",
"go.faq.a7": "Yes, you can cancel any time.",
"go.faq.q8": "Can I use Go with other coding agents?",
"go.faq.a8": "Yes, you can use Go with any agent. Follow the setup instructions in your preferred coding agent.",
"go.faq.q8": "Who can use Go?",
"go.faq.a8":
"Go is available to the named subscriber through OpenCode. Other coding agents and service accounts are not eligible.",
"go.faq.q9": "What is the difference between free models and Go?",
"go.faq.a9":
@@ -654,8 +656,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "Monthly Usage",
"workspace.lite.subscription.resetsIn": "Resets in",
"workspace.lite.subscription.useBalance": "Use your available balance after reaching the usage limits",
"workspace.lite.subscription.selectProvider":
'Select "OpenCode Go" as the provider in your opencode configuration to use Go models.',
"workspace.lite.subscription.selectProvider": 'Select the "opencode" provider to use Go models.',
"workspace.lite.providers.title": "Providers",
"workspace.lite.providers.description": "Control which providers are used for routing.",
"workspace.lite.providers.useChina": "Enable models hosted in China",
+12 -15
View File
@@ -269,8 +269,7 @@ export const dict = {
"go.cta.text": "Suscribirse a Go",
"go.cta.price": "10 $/mes",
"go.cta.promo": "$5 el primer mes",
"go.pricing.body":
"Úsalo con cualquier agente. $5 el primer mes, luego 10 $/mes. Recarga crédito si es necesario. Cancela en cualquier momento.",
"go.pricing.body": "Go comienza en $5 el primer mes, luego 10 $/mes.",
"go.graph.free": "Gratis",
"go.graph.freePill": "Big Pickle y modelos gratuitos",
"go.graph.go": "Go",
@@ -310,15 +309,15 @@ export const dict = {
"go.problem.item4":
"Incluye Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3",
"go.how.title": "Cómo funciona Go",
"go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.",
"go.how.step1.title": "Crear una cuenta",
"go.how.step1.beforeLink": "sigue las",
"go.how.step1.link": "instrucciones de configuración",
"go.how.step2.title": "Suscribirse a Go",
"go.how.step2.link": "$5 el primer mes",
"go.how.step2.afterLink": "luego 10 $/mes con límites generosos",
"go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes.",
"go.how.step1.title": "Suscribirse a Go",
"go.how.step1.beforeLink": "en",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "Conectar OpenCode",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "y autoriza el dispositivo en tu navegador",
"go.how.step3.title": "Empezar a programar",
"go.how.step3.body": "con acceso fiable a modelos de código abierto",
"go.how.step3.body": "con el proveedor opencode",
"go.privacy.title": "Tu privacidad es importante para nosotros",
"go.privacy.body":
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., UE y Singapur para un acceso global estable.",
@@ -351,9 +350,8 @@ export const dict = {
"go.faq.a6": "Si necesitas más uso, puedes recargar crédito en tu cuenta.",
"go.faq.q7": "¿Puedo cancelar?",
"go.faq.a7": "Sí, puedes cancelar en cualquier momento.",
"go.faq.q8": "¿Puedo usar Go con otros agentes de programación?",
"go.faq.a8":
"Sí, puedes usar Go con cualquier agente. Sigue las instrucciones de configuración en tu agente de programación preferido.",
"go.faq.q8": "¿Qué acceso está aplazado?",
"go.faq.a8": "La compatibilidad con agentes externos y cuentas de servicio está aplazada.",
"go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?",
"go.faq.a9":
@@ -660,8 +658,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "Uso Mensual",
"workspace.lite.subscription.resetsIn": "Se reinicia en",
"workspace.lite.subscription.useBalance": "Usa tu saldo disponible después de alcanzar los límites de uso",
"workspace.lite.subscription.selectProvider":
'Selecciona "OpenCode Go" como proveedor en tu configuración de opencode para usar los modelos Go.',
"workspace.lite.subscription.selectProvider": "Selecciona el proveedor opencode para usar los modelos de Go.",
"workspace.lite.providers.title": "Proveedores",
"workspace.lite.providers.description": "Controla qué proveedores se usan para el enrutamiento.",
"workspace.lite.providers.useChina": "Activar modelos alojados en China",
+12 -16
View File
@@ -270,8 +270,7 @@ export const dict = {
"go.cta.text": "S'abonner à Go",
"go.cta.price": "10 $/mois",
"go.cta.promo": "$5 le premier mois",
"go.pricing.body":
"Utilisez-le avec n'importe quel agent. $5 le premier mois, puis 10 $/mois. Rechargez du crédit si nécessaire. Annulez à tout moment.",
"go.pricing.body": "Go commence à $5 pour le premier mois, puis 10 $/mois.",
"go.graph.free": "Gratuit",
"go.graph.freePill": "Big Pickle et modèles gratuits",
"go.graph.go": "Go",
@@ -310,16 +309,15 @@ export const dict = {
"go.problem.item4":
"Inclut Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3",
"go.how.title": "Comment fonctionne Go",
"go.how.body":
"Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.",
"go.how.step1.title": "Créez un compte",
"go.how.step1.beforeLink": "suivez les",
"go.how.step1.link": "instructions de configuration",
"go.how.step2.title": "Abonnez-vous à Go",
"go.how.step2.link": "$5 le premier mois",
"go.how.step2.afterLink": "puis 10 $/mois avec des limites généreuses",
"go.how.body": "Go commence à $5 pour le premier mois, puis 10 $/mois.",
"go.how.step1.title": "Abonnez-vous à Go",
"go.how.step1.beforeLink": "dans",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "Connecter OpenCode",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "et autorisez lappareil dans votre navigateur",
"go.how.step3.title": "Commencez à coder",
"go.how.step3.body": "avec un accès fiable aux modèles open source",
"go.how.step3.body": "avec le fournisseur opencode",
"go.privacy.title": "Votre vie privée est importante pour nous",
"go.privacy.body":
"Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable.",
@@ -352,9 +350,8 @@ export const dict = {
"go.faq.a6": "Si vous avez besoin de plus d'utilisation, vous pouvez recharger du crédit dans votre compte.",
"go.faq.q7": "Puis-je annuler ?",
"go.faq.a7": "Oui, vous pouvez annuler à tout moment.",
"go.faq.q8": "Puis-je utiliser Go avec d'autres agents de code ?",
"go.faq.a8":
"Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.",
"go.faq.q8": "Quel accès est reporté ?",
"go.faq.a8": "La prise en charge des agents externes et des comptes de service est reportée.",
"go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?",
"go.faq.a9":
"Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3 avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).",
@@ -666,8 +663,7 @@ export const dict = {
"workspace.lite.subscription.resetsIn": "Réinitialisation dans",
"workspace.lite.subscription.useBalance":
"Utilisez votre solde disponible après avoir atteint les limites d'utilisation",
"workspace.lite.subscription.selectProvider":
'Sélectionnez "OpenCode Go" comme fournisseur dans votre configuration opencode pour utiliser les modèles Go.',
"workspace.lite.subscription.selectProvider": "Sélectionnez le fournisseur opencode pour utiliser les modèles Go.",
"workspace.lite.providers.title": "Fournisseurs",
"workspace.lite.providers.description": "Contrôlez les fournisseurs utilisés pour le routage.",
"workspace.lite.providers.useChina": "Activer les modèles hébergés en Chine",
+12 -15
View File
@@ -266,8 +266,7 @@ export const dict = {
"go.cta.text": "Abbonati a Go",
"go.cta.price": "$10/mese",
"go.cta.promo": "$5 il primo mese",
"go.pricing.body":
"Usalo con qualsiasi agente. $5 il primo mese, poi $10/mese. Ricarica il credito se necessario. Annulla in qualsiasi momento.",
"go.pricing.body": "Go inizia a $5 per il primo mese, poi $10/mese.",
"go.graph.free": "Gratis",
"go.graph.freePill": "Big Pickle e modelli gratuiti",
"go.graph.go": "Go",
@@ -306,15 +305,15 @@ export const dict = {
"go.problem.item4":
"Include Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3",
"go.how.title": "Come funziona Go",
"go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.",
"go.how.step1.title": "Crea un account",
"go.how.step1.beforeLink": "segui le",
"go.how.step1.link": "istruzioni di configurazione",
"go.how.step2.title": "Abbonati a Go",
"go.how.step2.link": "$5 il primo mese",
"go.how.step2.afterLink": "poi $10/mese con limiti generosi",
"go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese.",
"go.how.step1.title": "Abbonati a Go",
"go.how.step1.beforeLink": "in",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "Connetti OpenCode",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "e autorizza il dispositivo nel browser",
"go.how.step3.title": "Inizia a programmare",
"go.how.step3.body": "con accesso affidabile ai modelli open source",
"go.how.step3.body": "con il provider opencode",
"go.privacy.title": "La tua privacy è importante per noi",
"go.privacy.body":
"Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati negli Stati Uniti, UE e Singapore per un accesso globale stabile.",
@@ -347,9 +346,8 @@ export const dict = {
"go.faq.a6": "Se hai bisogno di più utilizzo, puoi ricaricare il credito nel tuo account.",
"go.faq.q7": "Posso annullare?",
"go.faq.a7": "Sì, puoi annullare in qualsiasi momento.",
"go.faq.q8": "Posso usare Go con altri agenti di coding?",
"go.faq.a8":
"Sì, puoi usare Go con qualsiasi agente. Segui le istruzioni di configurazione nel tuo agente di coding preferito.",
"go.faq.q8": "Quale accesso è rinviato?",
"go.faq.a8": "Il supporto per agenti esterni e account di servizio è rinviato.",
"go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?",
"go.faq.a9":
@@ -658,8 +656,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "Utilizzo Mensile",
"workspace.lite.subscription.resetsIn": "Si resetta tra",
"workspace.lite.subscription.useBalance": "Usa il tuo saldo disponibile dopo aver raggiunto i limiti di utilizzo",
"workspace.lite.subscription.selectProvider":
'Seleziona "OpenCode Go" come provider nella tua configurazione opencode per utilizzare i modelli Go.',
"workspace.lite.subscription.selectProvider": "Seleziona il provider opencode per usare i modelli Go.",
"workspace.lite.providers.title": "Provider",
"workspace.lite.providers.description": "Controlla quali provider vengono usati per il routing.",
"workspace.lite.providers.useChina": "Abilita modelli ospitati in Cina",
+12 -15
View File
@@ -265,8 +265,7 @@ export const dict = {
"go.cta.text": "Goを購読する",
"go.cta.price": "$10/月",
"go.cta.promo": "初月 $5",
"go.pricing.body":
"どのエージェントでも使えます。最初の月$5、その後$10/月。必要に応じてクレジットを追加。いつでもキャンセルできます。",
"go.pricing.body": "Goは最初の月$5、その後$10/月で始まります。",
"go.graph.free": "無料",
"go.graph.freePill": "Big Pickleと無料モデル",
"go.graph.go": "Go",
@@ -306,15 +305,15 @@ export const dict = {
"go.problem.item4":
"Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3を含む",
"go.how.title": "Goの仕組み",
"go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。",
"go.how.step1.title": "アカウントを作成",
"go.how.step1.beforeLink": "",
"go.how.step1.link": "セットアップ手順はこちら",
"go.how.step2.title": "Goを購読する",
"go.how.step2.link": "最初の月$5",
"go.how.step2.afterLink": "その後$10/月、ゆとりある上限付き",
"go.how.body": "Goは最初の月$5、その後$10/月で始まります。",
"go.how.step1.title": "Goを購読する",
"go.how.step1.beforeLink": "",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "OpenCodeを接続",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "ブラウザでデバイスを承認します",
"go.how.step3.title": "コーディングを開始",
"go.how.step3.body": "オープンソースモデルへの安定したアクセスで",
"go.how.step3.body": "opencodeプロバイダーで",
"go.privacy.title": "あなたのプライバシーは私たちにとって重要です",
"go.privacy.body":
"このプランは主に海外ユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。",
@@ -347,9 +346,8 @@ export const dict = {
"go.faq.a6": "利用枠を追加したい場合は、アカウントでクレジットをチャージできます。",
"go.faq.q7": "キャンセルできますか?",
"go.faq.a7": "はい、いつでもキャンセル可能です。",
"go.faq.q8": "他のコーディングエージェントでGoを使えますか?",
"go.faq.a8":
"はい、Goは任意のエージェントで使用できます。お使いのコーディングエージェントのセットアップ手順に従ってください。",
"go.faq.q8": "どのアクセスが延期されていますか?",
"go.faq.a8": "外部エージェントとサービスアカウントのサポートは延期されています。",
"go.faq.q9": "無料モデルとGoの違いは何ですか?",
"go.faq.a9":
@@ -658,8 +656,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "月間利用量",
"workspace.lite.subscription.resetsIn": "リセットまで",
"workspace.lite.subscription.useBalance": "利用限度額に達したら利用可能な残高を使用する",
"workspace.lite.subscription.selectProvider":
"Go モデルを使用するには、opencode の設定で「OpenCode Go」をプロバイダーとして選択してください。",
"workspace.lite.subscription.selectProvider": "Goモデルを使用するにはopencodeプロバイダーを選択してください。",
"workspace.lite.providers.title": "プロバイダー",
"workspace.lite.providers.description": "ルーティングに使用するプロバイダーを管理します。",
"workspace.lite.providers.useChina": "中国でホストされているモデルを有効にする",
+12 -14
View File
@@ -262,8 +262,7 @@ export const dict = {
"go.cta.text": "Go 구독하기",
"go.cta.price": "$10/월",
"go.cta.promo": "첫 달 $5",
"go.pricing.body":
"어떤 에이전트와도 사용할 수 있습니다. 첫 달 $5, 이후 $10/월. 필요하면 크레딧을 충전하세요. 언제든지 취소할 수 있습니다.",
"go.pricing.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다.",
"go.graph.free": "무료",
"go.graph.freePill": "Big Pickle 및 무료 모델",
"go.graph.go": "Go",
@@ -303,15 +302,15 @@ export const dict = {
"go.problem.item4":
"Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3 포함",
"go.how.title": "Go 작동 방식",
"go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.",
"go.how.step1.title": "계정 생성",
"go.how.step1.beforeLink": "",
"go.how.step1.link": "설정 지침을 따르세요",
"go.how.step2.title": "Go 구독",
"go.how.step2.link": "첫 달 $5",
"go.how.step2.afterLink": "이후 $10/월, 넉넉한 한도 포함",
"go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다.",
"go.how.step1.title": "Go 구독",
"go.how.step1.beforeLink": "에서",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "OpenCode 연결",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "브라우저에서 기기를 승인하세요",
"go.how.step3.title": "코딩 시작",
"go.how.step3.body": "오픈 소스 모델에 대한 안정적인 액세스와 함께",
"go.how.step3.body": "opencode 공급자로",
"go.privacy.title": "귀하의 프라이버시는 우리에게 중요합니다",
"go.privacy.body":
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU, 싱가포르에 모델이 호스팅되어 있습니다.",
@@ -343,8 +342,8 @@ export const dict = {
"go.faq.a6": "사용량이 더 필요한 경우 계정에서 크레딧을 충전할 수 있습니다.",
"go.faq.q7": "취소할 수 있나요?",
"go.faq.a7": "네, 언제든지 취소할 수 있습니다.",
"go.faq.q8": "다른 코딩 에이전트와 Go를 사용할 수 있나요?",
"go.faq.a8": "네, Go는 어떤 에이전트와도 사용할 수 있습니다. 선호하는 코딩 에이전트의 설정 지침을 따르세요.",
"go.faq.q8": "어떤 액세스가 연기되었나요?",
"go.faq.a8": "외부 에이전트와 서비스 계정 지원은 연기되었습니다.",
"go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?",
"go.faq.a9":
@@ -650,8 +649,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "월간 사용량",
"workspace.lite.subscription.resetsIn": "초기화까지 남은 시간:",
"workspace.lite.subscription.useBalance": "사용 한도 도달 후에는 보유 잔액 사용",
"workspace.lite.subscription.selectProvider":
'Go 모델을 사용하려면 opencode 설정에서 "OpenCode Go"를 공급자로 선택하세요.',
"workspace.lite.subscription.selectProvider": "Go 모델을 사용하려면 opencode 공급자를 선택하세요.",
"workspace.lite.providers.title": "공급자",
"workspace.lite.providers.description": "라우팅에 사용할 공급자를 제어합니다.",
"workspace.lite.providers.useChina": "중국에서 호스팅되는 모델 활성화",
+12 -16
View File
@@ -266,8 +266,7 @@ export const dict = {
"go.cta.text": "Abonner på Go",
"go.cta.price": "$10/måned",
"go.cta.promo": "$5 første måned",
"go.pricing.body":
"Bruk med hvilken som helst agent. $5 første måned, deretter $10/måned. Fyll på kreditt ved behov. Avslutt når som helst.",
"go.pricing.body": "Go starter på $5 for den første måneden, deretter $10/måned.",
"go.graph.free": "Gratis",
"go.graph.freePill": "Big Pickle og gratis modeller",
"go.graph.go": "Go",
@@ -306,16 +305,15 @@ export const dict = {
"go.problem.item4":
"Inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3",
"go.how.title": "Hvordan Go fungerer",
"go.how.body":
"Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.",
"go.how.step1.title": "Opprett en konto",
"go.how.step1.beforeLink": "følg",
"go.how.step1.link": "oppsettsinstruksjonene",
"go.how.step2.title": "Abonner på Go",
"go.how.step2.link": "$5 første måned",
"go.how.step2.afterLink": "deretter $10/måned med sjenerøse grenser",
"go.how.body": "Go starter på $5 for den første måneden, deretter $10/måned.",
"go.how.step1.title": "Abonner på Go",
"go.how.step1.beforeLink": "i",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "Koble til OpenCode",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "og godkjenn enheten i nettleseren",
"go.how.step3.title": "Begynn å kode",
"go.how.step3.body": "med pålitelig tilgang til åpen kildekode-modeller",
"go.how.step3.body": "med opencode-leverandøren",
"go.privacy.title": "Personvernet ditt er viktig for oss",
"go.privacy.body":
"Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang.",
@@ -348,9 +346,8 @@ export const dict = {
"go.faq.a6": "Hvis du trenger mer bruk, kan du fylle på kreditt i kontoen din.",
"go.faq.q7": "Kan jeg avslutte?",
"go.faq.a7": "Ja, du kan avslutte når som helst.",
"go.faq.q8": "Kan jeg bruke Go med andre kodeagenter?",
"go.faq.a8":
"Ja, du kan bruke Go med hvilken som helst agent. Følg oppsettinstruksjonene i din foretrukne kodeagent.",
"go.faq.q8": "Hvilken tilgang er utsatt?",
"go.faq.a8": "Støtte for eksterne agenter og tjenestekontoer er utsatt.",
"go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?",
"go.faq.a9":
@@ -657,8 +654,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "Månedlig bruk",
"workspace.lite.subscription.resetsIn": "Nullstilles om",
"workspace.lite.subscription.useBalance": "Bruk din tilgjengelige saldo etter å ha nådd bruksgrensene",
"workspace.lite.subscription.selectProvider":
'Velg "OpenCode Go" som leverandør i opencode-konfigurasjonen din for å bruke Go-modeller.',
"workspace.lite.subscription.selectProvider": "Velg opencode-leverandøren for å bruke Go-modeller.",
"workspace.lite.providers.title": "Leverandører",
"workspace.lite.providers.description": "Kontroller hvilke leverandører som brukes til ruting.",
"workspace.lite.providers.useChina": "Aktiver modeller hostet i Kina",
+12 -16
View File
@@ -267,8 +267,7 @@ export const dict = {
"go.cta.text": "Zasubskrybuj Go",
"go.cta.price": "$10/miesiąc",
"go.cta.promo": "$5 pierwszy miesiąc",
"go.pricing.body":
"Używaj z dowolnym agentem. $5 za pierwszy miesiąc, potem $10/miesiąc. Doładuj konto w razie potrzeby. Anuluj w dowolnym momencie.",
"go.pricing.body": "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc.",
"go.graph.free": "Darmowe",
"go.graph.freePill": "Big Pickle i darmowe modele",
"go.graph.go": "Go",
@@ -307,16 +306,15 @@ export const dict = {
"go.problem.item4":
"Zawiera Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3",
"go.how.title": "Jak działa Go",
"go.how.body":
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.",
"go.how.step1.title": "Załóż konto",
"go.how.step1.beforeLink": "postępuj zgodnie z",
"go.how.step1.link": "instrukcją konfiguracji",
"go.how.step2.title": "Zasubskrybuj Go",
"go.how.step2.link": "$5 za pierwszy miesiąc",
"go.how.step2.afterLink": "potem $10/miesiąc z hojnymi limitami",
"go.how.body": "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc.",
"go.how.step1.title": "Zasubskrybuj Go",
"go.how.step1.beforeLink": "w",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "Połącz OpenCode",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "i zatwierdź urządzenie w przeglądarce",
"go.how.step3.title": "Zacznij kodować",
"go.how.step3.body": "z niezawodnym dostępem do modeli open source",
"go.how.step3.body": "z dostawcą opencode",
"go.privacy.title": "Twoja prywatność jest dla nas ważna",
"go.privacy.body":
"Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp.",
@@ -349,9 +347,8 @@ export const dict = {
"go.faq.a6": "Jeśli potrzebujesz większego użycia, możesz doładować środki na swoim koncie.",
"go.faq.q7": "Czy mogę anulować?",
"go.faq.a7": "Tak, możesz anulować w dowolnym momencie.",
"go.faq.q8": "Czy mogę używać Go z innymi agentami kodującymi?",
"go.faq.a8":
"Tak, możesz używać Go z dowolnym agentem. Postępuj zgodnie z instrukcjami konfiguracji w swoim preferowanym agencie.",
"go.faq.q8": "Jaki dostęp jest odroczony?",
"go.faq.a8": "Obsługa zewnętrznych agentów i kont usług jest odroczona.",
"go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?",
"go.faq.a9":
@@ -658,8 +655,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "Użycie miesięczne",
"workspace.lite.subscription.resetsIn": "Resetuje się za",
"workspace.lite.subscription.useBalance": "Użyj dostępnego salda po osiągnięciu limitów użycia",
"workspace.lite.subscription.selectProvider":
'Wybierz "OpenCode Go" jako dostawcę w konfiguracji opencode, aby używać modeli Go.',
"workspace.lite.subscription.selectProvider": "Wybierz dostawcę opencode, aby używać modeli Go.",
"workspace.lite.providers.title": "Dostawcy",
"workspace.lite.providers.description": "Kontroluj, którzy dostawcy są używani do routingu.",
"workspace.lite.providers.useChina": "Włącz modele hostowane w Chinach",
+12 -16
View File
@@ -270,8 +270,7 @@ export const dict = {
"go.cta.text": "Подписаться на Go",
"go.cta.price": "$10/месяц",
"go.cta.promo": "$5 первый месяц",
"go.pricing.body":
"Используйте с любым агентом. $5 за первый месяц, затем $10/месяц. Пополняйте баланс при необходимости. Отменить можно в любое время.",
"go.pricing.body": "Go начинается с $5 за первый месяц, затем $10/месяц.",
"go.graph.free": "Бесплатно",
"go.graph.freePill": "Big Pickle и бесплатные модели",
"go.graph.go": "Go",
@@ -311,16 +310,15 @@ export const dict = {
"go.problem.item4":
"Включает Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3",
"go.how.title": "Как работает Go",
"go.how.body":
"Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.",
"go.how.step1.title": "Создайте аккаунт",
"go.how.step1.beforeLink": "следуйте",
"go.how.step1.link": "инструкциям по настройке",
"go.how.step2.title": "Подпишитесь на Go",
"go.how.step2.link": "$5 за первый месяц",
"go.how.step2.afterLink": "затем $10/месяц с щедрыми лимитами",
"go.how.body": "Go начинается с $5 за первый месяц, затем $10/месяц.",
"go.how.step1.title": "Подпишитесь на Go",
"go.how.step1.beforeLink": ",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "Подключить OpenCode",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "и подтвердите устройство в браузере",
"go.how.step3.title": "Начните кодить",
"go.how.step3.body": "с надежным доступом к open-source моделям",
"go.how.step3.body": "с провайдером opencode",
"go.privacy.title": "Ваша приватность важна для нас",
"go.privacy.body":
"План разработан в первую очередь для международных пользователей, с моделями, размещенными в США, ЕС и Сингапуре для стабильного глобального доступа.",
@@ -353,9 +351,8 @@ export const dict = {
"go.faq.a6": "Если вам нужно больше использования, вы можете пополнить баланс в своем аккаунте.",
"go.faq.q7": "Могу ли я отменить подписку?",
"go.faq.a7": "Да, вы можете отменить подписку в любое время.",
"go.faq.q8": "Могу ли я использовать Go с другими кодинг-агентами?",
"go.faq.a8":
"Да, вы можете использовать Go с любым агентом. Следуйте инструкциям по настройке в вашем предпочитаемом агенте.",
"go.faq.q8": "Какая поддержка отложена?",
"go.faq.a8": "Поддержка внешних агентов и сервисных аккаунтов отложена.",
"go.faq.q9": "В чем разница между бесплатными моделями и Go?",
"go.faq.a9":
@@ -664,8 +661,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "Ежемесячное использование",
"workspace.lite.subscription.resetsIn": "Сброс через",
"workspace.lite.subscription.useBalance": "Использовать доступный баланс после достижения лимитов",
"workspace.lite.subscription.selectProvider":
'Выберите "OpenCode Go" в качестве провайдера в настройках opencode для использования моделей Go.',
"workspace.lite.subscription.selectProvider": "Выберите провайдер opencode для использования моделей Go.",
"workspace.lite.providers.title": "Провайдеры",
"workspace.lite.providers.description": "Управляйте провайдерами, используемыми для маршрутизации.",
"workspace.lite.providers.useChina": "Включить модели, размещенные в Китае",
+12 -13
View File
@@ -265,7 +265,7 @@ export const dict = {
"go.cta.text": "สมัครสมาชิก Go",
"go.cta.price": "$10/เดือน",
"go.cta.promo": "$5 เดือนแรก",
"go.pricing.body": "ใช้กับเอเจนต์ใดก็ได้ $5 ในเดือนแรก จากนั้น $10/เดือน เติมเครดิตหากจำเป็น ยกเลิกได้ตลอดเวลา",
"go.pricing.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน",
"go.graph.free": "ฟรี",
"go.graph.freePill": "Big Pickle และโมเดลฟรี",
"go.graph.go": "Go",
@@ -304,15 +304,15 @@ export const dict = {
"go.problem.item4":
"รวมถึง Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3",
"go.how.title": "Go ทำงานอย่างไร",
"go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้",
"go.how.step1.title": "สร้างบัญชี",
"go.how.step1.beforeLink": "ทำตาม",
"go.how.step1.link": "คำแนะนำการตั้งค่า",
"go.how.step2.title": "สมัครสมาชิก Go",
"go.how.step2.link": "$5 เดือนแรก",
"go.how.step2.afterLink": "จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อ",
"go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน",
"go.how.step1.title": "สมัครสมาชิก Go",
"go.how.step1.beforeLink": "ใน",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "เชื่อมต่อ OpenCode",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "และอนุมัติอุปกรณ์ในเบราว์เซอร์",
"go.how.step3.title": "เริ่มเขียนโค้ด",
"go.how.step3.body": "ด้วยการเข้าถึงโมเดลโอเพนซอร์สที่เชื่อถือได้",
"go.how.step3.body": "ด้วยผู้ให้บริการ opencode",
"go.privacy.title": "ความเป็นส่วนตัวของคุณสำคัญสำหรับเรา",
"go.privacy.body":
"แผนนี้ออกแบบมาเพื่อผู้ใช้งานระหว่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงทั่วโลกที่เสถียร",
@@ -345,8 +345,8 @@ export const dict = {
"go.faq.a6": "หากคุณต้องการใช้งานเพิ่ม คุณสามารถเติมเครดิตในบัญชีของคุณได้",
"go.faq.q7": "ฉันสามารถยกเลิกได้หรือไม่?",
"go.faq.a7": "ได้ คุณสามารถยกเลิกได้ตลอดเวลา",
"go.faq.q8": "ฉันสามารถใช้ Go กับเอเจนต์เขียนโค้ดอื่นได้หรือไม่?",
"go.faq.a8": "ได้ คุณสามารถใช้ Go กับเอเจนต์ใดก็ได้ ทำตามคำแนะนำการตั้งค่าในเอเจนต์เขียนโค้ดที่คุณต้องการ",
"go.faq.q8": "การเข้าถึงใดถูกเลื่อนออกไป?",
"go.faq.a8": "การรองรับเอเจนต์ภายนอกและบัญชีบริการถูกเลื่อนออกไป",
"go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?",
"go.faq.a9":
@@ -653,8 +653,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "การใช้งานรายเดือน",
"workspace.lite.subscription.resetsIn": "รีเซ็ตใน",
"workspace.lite.subscription.useBalance": "ใช้ยอดคงเหลือของคุณหลังจากถึงขีดจำกัดการใช้งาน",
"workspace.lite.subscription.selectProvider":
'เลือก "OpenCode Go" เป็นผู้ให้บริการในการตั้งค่า opencode ของคุณเพื่อใช้โมเดล Go',
"workspace.lite.subscription.selectProvider": "เลือกผู้ให้บริการ opencode เพื่อใช้โมเดล Go",
"workspace.lite.providers.title": "ผู้ให้บริการ",
"workspace.lite.providers.description": "ควบคุมผู้ให้บริการที่ใช้สำหรับการกำหนดเส้นทาง",
"workspace.lite.providers.useChina": "เปิดใช้โมเดลที่โฮสต์ในจีน",
+12 -16
View File
@@ -268,8 +268,7 @@ export const dict = {
"go.cta.text": "Go'ya abone ol",
"go.cta.price": "Ayda 10$",
"go.cta.promo": "İlk ay $5",
"go.pricing.body":
"Herhangi bir ajanla kullanın. İlk ay $5, sonrasında ayda 10$. Gerekirse kredi yükleyin. İstediğiniz zaman iptal edin.",
"go.pricing.body": "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar.",
"go.graph.free": "Ücretsiz",
"go.graph.freePill": "Big Pickle ve ücretsiz modeller",
"go.graph.go": "Go",
@@ -309,16 +308,15 @@ export const dict = {
"go.problem.item4":
"Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 içerir",
"go.how.title": "Go nasıl çalışır?",
"go.how.body":
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.",
"go.how.step1.title": "Bir hesap oluşturun",
"go.how.step1.beforeLink": "takip edin",
"go.how.step1.link": "kurulum talimatları",
"go.how.step2.title": "Go'ya abone olun",
"go.how.step2.link": "İlk ay $5",
"go.how.step2.afterLink": "sonrasında cömert limitlerle ayda 10$",
"go.how.body": "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar.",
"go.how.step1.title": "Go'ya abone olun",
"go.how.step1.beforeLink": "içinde",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "OpenCodeu bağlayın",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "ve cihazı tarayıcınızda onaylayın",
"go.how.step3.title": "Kodlamaya başlayın",
"go.how.step3.body": "açık kaynaklı modellere güvenilir erişimle",
"go.how.step3.body": "opencode sağlayıcısıyla",
"go.privacy.title": "Gizliliğiniz bizim için önemlidir",
"go.privacy.body":
"Bu plan öncelikle uluslararası kullanıcılar için tasarlanmış olup, istikrarlı küresel erişim için modeller ABD, AB ve Singapur'da barındırılmaktadır.",
@@ -351,9 +349,8 @@ export const dict = {
"go.faq.a6": "Daha fazla kullanıma ihtiyacınız varsa, hesabınıza kredi yükleyebilirsiniz.",
"go.faq.q7": "İptal edebilir miyim?",
"go.faq.a7": "Evet, istediğiniz zaman iptal edebilirsiniz.",
"go.faq.q8": "Go'yu diğer kodlama ajanlarıyla kullanabilir miyim?",
"go.faq.a8":
"Evet, Go'yu herhangi bir ajanla kullanabilirsiniz. Tercih ettiğiniz kodlama ajanındaki kurulum talimatlarını izleyin.",
"go.faq.q8": "Hangi erişim ertelendi?",
"go.faq.a8": "Harici ajan ve hizmet hesabı desteği ertelendi.",
"go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?",
"go.faq.a9":
@@ -660,8 +657,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "Aylık Kullanım",
"workspace.lite.subscription.resetsIn": "Sıfırlama süresi",
"workspace.lite.subscription.useBalance": "Kullanım limitlerine ulaştıktan sonra mevcut bakiyenizi kullanın",
"workspace.lite.subscription.selectProvider":
'Go modellerini kullanmak için opencode yapılandırmanızda "OpenCode Go"\'yu sağlayıcı olarak seçin.',
"workspace.lite.subscription.selectProvider": "Go modellerini kullanmak için opencode sağlayıcısını seçin.",
"workspace.lite.providers.title": "Sağlayıcılar",
"workspace.lite.providers.description": "Yönlendirme için hangi sağlayıcıların kullanılacağını kontrol edin.",
"workspace.lite.providers.useChina": "Çin'de barındırılan modelleri etkinleştir",
+12 -14
View File
@@ -267,8 +267,7 @@ export const dict = {
"go.cta.text": "Підписатися на Go",
"go.cta.price": "$10/місяць",
"go.cta.promo": "$5 перший місяць",
"go.pricing.body":
"Використовуйте з будь-яким агентом. $5 перший місяць, потім $10/місяць. Поповнюйте за потреби. Скасуйте в будь-який час.",
"go.pricing.body": "Go починається від $5 за перший місяць, потім $10/місяць.",
"go.graph.free": "Безкоштовно",
"go.graph.freePill": "Big Pickle та безкоштовні моделі",
"go.graph.go": "Go",
@@ -307,16 +306,15 @@ export const dict = {
"go.problem.item4":
"Включає Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash та Hy3",
"go.how.title": "Як працює Go",
"go.how.body":
"Go починається від $5 за перший місяць, потім $10/місяць. Використовуйте з OpenCode або будь-яким агентом.",
"go.how.step1.title": "Створіть обліковий запис",
"go.how.step1.beforeLink": "дотримуйтесь",
"go.how.step1.link": "інструкцій з налаштування",
"go.how.step2.title": "Підпишіться на Go",
"go.how.step2.link": "$5 перший місяць",
"go.how.step2.afterLink": "потім $10/місяць із щедрими лімітами",
"go.how.body": "Go починається від $5 за перший місяць, потім $10/місяць.",
"go.how.step1.title": "Підпишіться на Go",
"go.how.step1.beforeLink": ",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "Підключити OpenCode",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "і підтвердьте пристрій у браузері",
"go.how.step3.title": "Почніть кодувати",
"go.how.step3.body": надійним доступом до моделей з відкритим кодом",
"go.how.step3.body": провайдером opencode",
"go.privacy.title": "Ваша конфіденційність важлива для нас",
"go.privacy.body":
"План розроблений переважно для міжнародних користувачів, з моделями, розміщеними в США, ЄС та Сінгапурі для стабільного глобального доступу.",
@@ -349,8 +347,8 @@ export const dict = {
"go.faq.a6": "Якщо вам потрібно більше використання, ви можете поповнити баланс в обліковому записі.",
"go.faq.q7": "Чи можна скасувати?",
"go.faq.a7": "Так, ви можете скасувати в будь-який час.",
"go.faq.q8": "Чи можна використовувати Go з іншими агентами кодування?",
"go.faq.a8": "Так, ви можете використовувати Go з будь-яким агентом.",
"go.faq.q8": "Яку підтримку відкладено?",
"go.faq.a8": "Підтримку зовнішніх агентів і сервісних акаунтів відкладено.",
"go.faq.q9": "Яка різниця між безкоштовними моделями та Go?",
"go.faq.a9":
@@ -657,7 +655,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "Місячне використання",
"workspace.lite.subscription.resetsIn": "Скидається через",
"workspace.lite.subscription.useBalance": "Використовуйте доступний баланс після досягнення лімітів",
"workspace.lite.subscription.selectProvider": 'Виберіть "OpenCode Go" як провайдера в конфігурації opencode.',
"workspace.lite.subscription.selectProvider": "Виберіть провайдер opencode, щоб використовувати моделі Go.",
"workspace.lite.providers.title": "Провайдери",
"workspace.lite.providers.description": "Керуйте провайдерами, які використовуються для маршрутизації.",
"workspace.lite.providers.useChina": "Увімкнути моделі, розміщені в Китаї",
+12 -13
View File
@@ -256,7 +256,7 @@ export const dict = {
"go.cta.text": "订阅 Go",
"go.cta.price": "$10/月",
"go.cta.promo": "首月 $5",
"go.pricing.body": "可配合任何代理使用。首月 $5,之后 $10/月。如有需要可充值。随时取消。",
"go.pricing.body": "Go 起价为首月 $5,之后 $10/月。",
"go.graph.free": "免费",
"go.graph.freePill": "Big Pickle 和免费模型",
"go.graph.go": "Go",
@@ -295,15 +295,15 @@ export const dict = {
"go.problem.item4":
"包含 Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 和 Hy3",
"go.how.title": "Go 如何工作",
"go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。",
"go.how.step1.title": "创建账户",
"go.how.step1.beforeLink": "遵循",
"go.how.step1.link": "设置说明",
"go.how.step2.title": "订阅 Go",
"go.how.step2.link": "首月 $5",
"go.how.step2.afterLink": "之后 $10/月,额度充裕",
"go.how.body": "Go 起价为首月 $5,之后 $10/月。",
"go.how.step1.title": "订阅 Go",
"go.how.step1.beforeLink": "",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "连接 OpenCode",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "并在浏览器中批准设备",
"go.how.step3.title": "开始编程",
"go.how.step3.body": "可靠访问开源模型",
"go.how.step3.body": "使用 opencode 提供商",
"go.privacy.title": "您的隐私对我们很重要",
"go.privacy.body": "该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保稳定的全球访问。",
"go.privacy.contactAfter": "如果您有任何问题。",
@@ -332,8 +332,8 @@ export const dict = {
"go.faq.a6": "如果您需要更多用量,可以在账户中充值余额。",
"go.faq.q7": "我可以取消吗?",
"go.faq.a7": "可以,您可以随时取消。",
"go.faq.q8": "我可以在其他编程代理中使用 Go 吗",
"go.faq.a8": "可以,您可以在任何代理中使用 Go。请遵循您首选编程代理中的设置说明。",
"go.faq.q8": "哪些访问支持尚未推出",
"go.faq.a8": "外部代理和服务账户支持尚未推出。",
"go.faq.q9": "免费模型和 Go 之间的区别是什么?",
"go.faq.a9":
@@ -634,8 +634,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "每月用量",
"workspace.lite.subscription.resetsIn": "重置于",
"workspace.lite.subscription.useBalance": "达到使用限额后使用您的可用余额",
"workspace.lite.subscription.selectProvider":
"在你的 opencode 配置中选择「OpenCode Go」作为提供商,即可使用 Go 模型。",
"workspace.lite.subscription.selectProvider": "选择 opencode 提供商以使用 Go 模型。",
"workspace.lite.providers.title": "提供商",
"workspace.lite.providers.description": "控制用于路由的提供商。",
"workspace.lite.providers.useChina": "启用部署在中国的模型",
+12 -13
View File
@@ -256,7 +256,7 @@ export const dict = {
"go.cta.text": "訂閱 Go",
"go.cta.price": "$10/月",
"go.cta.promo": "首月 $5",
"go.pricing.body": "可搭配任何代理使用。首月 $5,之後 $10/月。如有需要可儲值。隨時取消。",
"go.pricing.body": "Go 起價為首月 $5,之後 $10/月。",
"go.graph.free": "免費",
"go.graph.freePill": "Big Pickle 與免費模型",
"go.graph.go": "Go",
@@ -295,15 +295,15 @@ export const dict = {
"go.problem.item4":
"包含 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 與 Hy3",
"go.how.title": "Go 如何運作",
"go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。",
"go.how.step1.title": "建立帳號",
"go.how.step1.beforeLink": "遵循",
"go.how.step1.link": "設定說明",
"go.how.step2.title": "訂閱 Go",
"go.how.step2.link": "首月 $5",
"go.how.step2.afterLink": "之後 $10/月,額度充裕",
"go.how.body": "Go 起價為首月 $5,之後 $10/月。",
"go.how.step1.title": "訂閱 Go",
"go.how.step1.beforeLink": "",
"go.how.step1.link": "OpenCode Console",
"go.how.step2.title": "連接 OpenCode",
"go.how.step2.link": "opencode2 console login",
"go.how.step2.afterLink": "並在瀏覽器中核准裝置",
"go.how.step3.title": "開始編碼",
"go.how.step3.body": "穩定存取開源模型",
"go.how.step3.body": "使用 opencode 提供商",
"go.privacy.title": "你的隱私對我們很重要",
"go.privacy.body": "該方案主要面向國際用戶設計,模型託管在美國、歐盟和新加坡,以確保全球穩定存取。",
"go.privacy.contactAfter": "如果你有任何問題。",
@@ -332,8 +332,8 @@ export const dict = {
"go.faq.a6": "如果你需要更多使用量,可以在帳戶中儲值額度。",
"go.faq.q7": "我可以取消嗎?",
"go.faq.a7": "可以,你可以隨時取消。",
"go.faq.q8": "我可以在其他編碼代理中使用 Go 嗎",
"go.faq.a8": "可以,你可以將 Go 與任何代理一起使用。請在你偏好的編碼代理中按照設定說明進行配置。",
"go.faq.q8": "哪些存取支援尚未推出",
"go.faq.a8": "外部代理和服務帳戶支援尚未推出。",
"go.faq.q9": "免費模型與 Go 有什麼區別?",
"go.faq.a9":
@@ -634,8 +634,7 @@ export const dict = {
"workspace.lite.subscription.monthlyUsage": "每月使用量",
"workspace.lite.subscription.resetsIn": "重置時間:",
"workspace.lite.subscription.useBalance": "達到使用限制後使用您的可用餘額",
"workspace.lite.subscription.selectProvider":
"在您的 opencode 設定中選擇「OpenCode Go」作為提供商,即可使用 Go 模型。",
"workspace.lite.subscription.selectProvider": "選擇 opencode 提供商以使用 Go 模型。",
"workspace.lite.providers.title": "提供商",
"workspace.lite.providers.description": "控制用於路由的提供商。",
"workspace.lite.providers.useChina": "啟用部署在中國的模型",
+6 -17
View File
@@ -1,7 +1,6 @@
import "./index.css"
import { createAsync, query } from "@solidjs/router"
import { Title, Meta } from "@solidjs/meta"
import { For, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { For, createSignal, onCleanup, onMount } from "solid-js"
//import { HttpHeader } from "@solidjs/start"
import goLogoLight from "../../asset/go-ornate-light.svg"
import goLogoDark from "../../asset/go-ornate-dark.svg"
@@ -11,17 +10,11 @@ import { Legal } from "~/component/legal"
import { Footer } from "~/component/footer"
import { Header } from "~/component/header"
import { config } from "~/config"
import { getLastSeenWorkspaceID } from "../workspace/common"
import { IconMiniMax, IconMiMo, IconZai, IconAlibaba, IconDeepSeek } from "~/component/icon"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { LocaleLinks } from "~/component/locale-links"
const checkLoggedIn = query(async () => {
"use server"
return await getLastSeenWorkspaceID().catch(() => undefined)
}, "checkLoggedIn.get")
const models = [
"Grok 4.5",
"GLM-5.2",
@@ -188,8 +181,7 @@ function LimitsGraph(props: { href: string }) {
}
export default function Home() {
const workspaceID = createAsync(() => checkLoggedIn())
const subscribeUrl = createMemo(() => (workspaceID() ? `/workspace/${workspaceID()}/go` : "/auth"))
const subscribeUrl = "/console/go"
const i18n = useI18n()
const language = useLanguage()
return (
@@ -207,8 +199,6 @@ export default function Home() {
<Meta name="twitter:title" content={i18n.t("go.title")} />
<Meta name="twitter:description" content={i18n.t("go.meta.description")} />
<Meta name="twitter:image" content="/social-share-black.png" />
<Meta name="opencode:auth" content={workspaceID() ? "true" : "false"} />
<div data-component="container">
<Header go hideGetStarted />
@@ -309,7 +299,7 @@ export default function Home() {
</div>
*/}
</div>
<a href={subscribeUrl()}>
<a href={subscribeUrl}>
<span>
<For
each={i18n
@@ -382,7 +372,7 @@ export default function Home() {
<span>[1]</span>
<div>
<strong>{i18n.t("go.how.step1.title")}</strong> - {i18n.t("go.how.step1.beforeLink")}{" "}
<a href={language.route("/docs/go/#how-it-works")} title={i18n.t("go.how.step1.link")}>
<a href="/console/go" title={i18n.t("go.how.step1.link")}>
{i18n.t("go.how.step1.link")}
</a>
</div>
@@ -391,8 +381,7 @@ export default function Home() {
<span>[2]</span>
<div>
<strong>{i18n.t("go.how.step2.title")}</strong> -{" "}
<a href={language.route("/docs/go/#pricing")}>{i18n.t("go.how.step2.link")}</a>{" "}
{i18n.t("go.how.step2.afterLink")}
<a href="/v2/docs/go">{i18n.t("go.how.step2.link")}</a> {i18n.t("go.how.step2.afterLink")}
</div>
</li>
<li>
@@ -429,7 +418,7 @@ export default function Home() {
{i18n.t("go.faq.a4.p1.beforePricing")}{" "}
<a href={language.route("/docs/go/#pricing")}>{i18n.t("go.faq.a4.p1.pricingLink")}</a>{" "}
{i18n.t("go.faq.a4.p1.afterPricing")} {i18n.t("go.faq.a4.p2.beforeAccount")}{" "}
<a href={subscribeUrl()}>{i18n.t("go.faq.a4.p2.accountLink")}</a>. {i18n.t("go.faq.a4.p3")}
<a href={subscribeUrl}>{i18n.t("go.faq.a4.p2.accountLink")}</a>. {i18n.t("go.faq.a4.p3")}
</Faq>
</li>
<li>
-8
View File
@@ -41,11 +41,6 @@
"node": "./src/image/photon-wasm.node.ts",
"default": "./src/image/photon-wasm.bun.ts"
},
"#shell-parser-wasm": {
"bun": "./src/shell/parser-wasm.bun.ts",
"node": "./src/shell/parser-wasm.node.ts",
"default": "./src/shell/parser-wasm.bun.ts"
},
"#process-lock-ffi": {
"bun": "./src/util/process-lock-ffi.bun.ts",
"node": "./src/util/process-lock-ffi.node.ts",
@@ -124,10 +119,7 @@
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"turndown": "7.2.0",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"venice-ai-sdk-provider": "2.1.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:"
},
+3 -3
View File
@@ -1,9 +1,9 @@
{
"version": "7",
"dialect": "sqlite",
"id": "e43ed7e2-b9fc-4178-beae-3646e4a976e1",
"id": "db37a97f-9b5e-4c87-be8b-4feace35136c",
"prevIds": [
"db37a97f-9b5e-4c87-be8b-4feace35136c"
"a4ba73b4-21bc-41ab-a415-94e2ca38d798"
],
"ddl": [
{
@@ -1302,7 +1302,7 @@
},
{
"type": "text",
"notNull": false,
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
-90
View File
@@ -1,90 +0,0 @@
export * as AISDKNative from "./aisdk-native"
import { isRecord } from "@opencode-ai/ai/utils/record"
export interface Mapping {
readonly package: string
readonly settings: Readonly<Record<string, unknown>>
}
export function map(packageName: string | undefined, settings: Readonly<Record<string, unknown>>): Mapping | undefined {
const baseSettings = mapBaseSettings(settings)
switch (packageName) {
case "@ai-sdk/google":
return {
package: "@opencode-ai/ai/providers/google",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapGoogleOptions(settings),
},
}
case "@openrouter/ai-sdk-provider":
return {
package: "@opencode-ai/ai/providers/openrouter",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapProviderOptions("openrouter", settings),
},
}
case "@ai-sdk/xai":
return {
package: "@opencode-ai/ai/providers/xai",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapXAIOptions(settings),
},
}
}
}
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
return {
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
}
}
function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
}
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
const input = settings.thinkingConfig
const thinkingConfig = {
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
...(isRecord(input) && typeof input.includeThoughts === "boolean"
? { includeThoughts: input.includeThoughts }
: {}),
...(isRecord(input) && typeof input.thinkingLevel === "string" ? { thinkingLevel: input.thinkingLevel } : {}),
}
const options = {
...(typeof settings.cachedContent === "string" ? { cachedContent: settings.cachedContent } : {}),
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: { gemini: options } }
}
function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: { xai: options } }
}
function mapProviderOptions(namespace: string, settings: Readonly<Record<string, unknown>>) {
const values = Object.fromEntries(
Object.entries(settings).filter(
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
),
)
if (Object.keys(values).length === 0) return {}
return { providerOptions: { [namespace]: values } }
}
-1
View File
@@ -58,6 +58,5 @@ export const migrations = (
import("./migration/20260722011141_delete_tool_progress_events"),
import("./migration/20260722170000_canonical_tool_results"),
import("./migration/20260729022634_session_fork_boundary"),
import("./migration/20260730195856_optional_session_title"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -1,14 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260730195856_optional_session_title",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` RENAME COLUMN \`title\` TO \`title_old\``)
yield* tx.run(`ALTER TABLE \`session\` ADD COLUMN \`title\` text`)
yield* tx.run(`UPDATE \`session\` SET \`title\` = \`title_old\``)
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`title_old\``)
})
},
} satisfies DatabaseMigration.Migration
+1 -1
View File
@@ -217,7 +217,7 @@ export default {
\`slug\` text NOT NULL,
\`directory\` text NOT NULL,
\`path\` text,
\`title\` text,
\`title\` text NOT NULL,
\`version\` text NOT NULL,
\`share_url\` text,
\`summary_additions\` integer,
+3
View File
@@ -54,6 +54,9 @@ const layer = Layer.effect(
})
formatters = builtIns
if (configured === true) return
if (configured.ruff?.disabled || configured.uv?.disabled) {
formatters = formatters.filter((formatter) => formatter.name !== "ruff" && formatter.name !== "uv")
}
for (const [name, entry] of Object.entries(configured)) {
const index = formatters.findIndex((formatter) => formatter.name === name)
+1
View File
@@ -18,6 +18,7 @@ export function make(input: {
readonly fs: FSUtil.Interface
readonly npm: Npm.Interface
readonly processes: AppProcess.Interface
readonly experimentalOxfmt?: boolean
}) {
const disabled = false as const
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
+1 -1
View File
@@ -201,7 +201,7 @@ const layer = Layer.effect(
locks.withLock(repository.gitDirectory)(effect)
const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) {
const dotgit = yield* fs.up({ targets: [".git"], start: input, mode: "first" }).pipe(
const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
)
+20 -6
View File
@@ -404,6 +404,7 @@ const layer = Layer.effect(
.get()
.integrations.get(attempt.integrationID)
?.implementations.get(attempt.methodID)
const previous = (yield* credentials.list(attempt.integrationID)).at(-1)
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
Effect.flatMap((label) =>
credentials.create({
@@ -412,11 +413,26 @@ const layer = Layer.effect(
value: exit.value,
}),
),
Effect.asVoid,
Effect.exit,
)
const settled = Exit.isSuccess(persistence)
? yield* Effect.gen(function* () {
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
}).pipe(Effect.exit)
: persistence
if (Exit.isFailure(settled) && Exit.isSuccess(persistence)) {
yield* credentials.remove(persistence.value.id)
if (previous) {
yield* credentials.create({
integrationID: previous.integrationID,
label: previous.label,
value: previous.value,
})
}
}
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalAttempt = Exit.isSuccess(persistence)
const terminal: TerminalAttempt = Exit.isSuccess(settled)
? {
status: "complete",
integrationID: attempt.integrationID,
@@ -426,15 +442,13 @@ const layer = Layer.effect(
: {
status: "failed",
integrationID: attempt.integrationID,
message: message(persistence.cause),
message: message(settled.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
// Persisting attempts cannot be cancelled, expired, or claimed again.
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
if (Exit.isFailure(settled)) yield* Effect.failCause(settled.cause)
}).pipe(Effect.ensuring(close(attempt.scope)))
}),
)
+31 -9
View File
@@ -12,12 +12,12 @@ import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
import { AISDK } from "./aisdk"
import { AISDKNative } from "./aisdk-native"
import { Catalog } from "./catalog"
import { Credential } from "./credential"
import { Integration } from "./integration"
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
import { Npm } from "@opencode-ai/util/npm"
import { OpenAICodex } from "./plugin/provider/openai-codex"
import { Provider } from "./provider"
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
@@ -152,7 +152,12 @@ export const fromCatalogModel = (
const packageName = Provider.packageName(resolved.package)
const key = apiKey(resolved, credential)
if (OpenAICodex.isChatGPT(credential) && !Provider.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
return Effect.succeed(codexModel(resolved, credential, key))
}
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
@@ -177,10 +182,7 @@ export const fromCatalogModel = (
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
const configured = { ...resolved.settings, ...credential?.metadata }
const mapping = Provider.isAISDK(resolved.package) ? AISDKNative.map(packageName, configured) : undefined
const native = mapping?.package ?? resolved.package
if (Provider.isAISDK(resolved.package) && !mapping) {
if (Provider.isAISDK(resolved.package)) {
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
@@ -191,16 +193,16 @@ export const fromCatalogModel = (
})
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!native) return Effect.fail(unsupported(resolved))
if (!resolved.package) return Effect.fail(unsupported(resolved))
const specifier = native
const specifier = resolved.package
return Effect.gen(function* () {
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const mapped = mapping?.settings ?? configured
const configured = { ...resolved.settings, ...credential?.metadata }
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...(credential ? withoutNativeAuthSettings(configured) : configured),
...nativeCredentialSettings(specifier, credential),
headers: resolved.headers,
body: resolved.body,
@@ -221,6 +223,10 @@ export const fromCatalogModel = (
})
}
const isNativeOpenAI = (packageName: string | undefined) =>
packageName === "@opencode-ai/ai/providers/openai" ||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
if (!credential) return {}
if (credential.type === "key") return { apiKey: credential.key }
@@ -242,6 +248,22 @@ const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
return rest
}
const codexModel = (
model: Info,
credential: Credential.Value | undefined,
key: ReturnType<typeof Auth.value> | undefined,
) => {
const account = OpenAICodex.accountID(credential)
return withDefaults(model, OpenAIResponses.route)
.with({
endpoint: { baseURL: OpenAICodex.baseURL },
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
),
})
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
}
const unsupported = (model: Info) =>
new UnsupportedPackageError({
providerID: model.providerID,
+2
View File
@@ -11,6 +11,8 @@ import { Permission } from "../permission"
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
const BUILD_SYSTEM =
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
@@ -0,0 +1,42 @@
export * as OpenAICodex from "./openai-codex"
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
// Codex routing lives in ModelResolver and catalog filtering.
// in OpenAIPlugin, sharing this module. Once the native provider packages land
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
// plan-eligibility data for OpenAI today, but models other vendors' subscriptions
// as dedicated providers (e.g. zai-coding-plan) - a future openai-chatgpt-plan
// provider entry could replace the hardcoded rules with catalog data.
/** ChatGPT-plan requests must target the codex backend instead of the public API. */
export const baseURL = "https://chatgpt.com/backend-api/codex"
const methodIDs: readonly string[] = ["chatgpt-browser", "chatgpt-headless"]
/** Structural credential shape so both core and plugin-facing credential types fit. */
type CredentialLike = {
readonly type: string
readonly methodID?: string
readonly metadata?: Record<string, unknown> | undefined
}
export const isChatGPT = (credential: CredentialLike | undefined) =>
credential?.type === "oauth" && credential.methodID !== undefined && methodIDs.includes(credential.methodID)
export const accountID = (credential: CredentialLike | undefined) => {
if (!isChatGPT(credential)) return undefined
const value = credential?.metadata?.accountID
return typeof value === "string" ? value : undefined
}
const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
const disallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
/** Which API model ids a ChatGPT subscription may call through the codex backend. */
export const eligible = (apiID: string) => {
if (allowed.has(apiID)) return true
if (disallowed.has(apiID)) return false
const match = apiID.match(/^gpt-(\d+\.\d+)/)
return match ? Number.parseFloat(match[1]) > 5.4 : false
}
+24 -40
View File
@@ -10,16 +10,14 @@ import { Model } from "../../model"
import { OauthCallbackPage } from "../../oauth/page"
import { Provider } from "../../provider"
import type { PluginInternal } from "../internal"
import { OpenAICodex } from "./openai-codex"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
const issuer = "https://auth.openai.com"
const callbackPort = 1455
const pollingSafetyMargin = 3000
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
const codexAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
const codexDisallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
type Pkce = {
verifier: string
@@ -166,18 +164,14 @@ export const OpenAIPlugin = define({
effect: Effect.fn(function* (ctx) {
const bus = yield* Bus.Service
const loading = Semaphore.makeUnsafe(1)
let chatgpt: Credential.OAuth | undefined
let chatgpt = false
const load = Effect.fn("OpenAIPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("openai")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
chatgpt =
credential?.type === "oauth" &&
(credential.methodID === browserMethodID || credential.methodID === headlessMethodID)
? credential
: undefined
chatgpt = OpenAICodex.isChatGPT(credential)
})
yield* ctx.integration.transform((draft) => {
@@ -189,19 +183,16 @@ export const OpenAIPlugin = define({
for (const item of evt.provider.list()) {
if (!Provider.isAISDK(item.provider.package)) continue
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai") continue
evt.provider.update(item.provider.id, (provider) => {
provider.package = "@opencode-ai/ai/providers/openai"
if (!item.models.has(Model.ID.make("gpt-5-chat-latest"))) continue
evt.model.update(item.provider.id, Model.ID.make("gpt-5-chat-latest"), (model) => {
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
// chat-completions-only model, so hide it only from OpenAI's catalog.
model.enabled = false
})
}
if (!chatgpt) return
const item = evt.provider.get(Provider.ID.openai)
if (!item) return
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL })
const account = chatgpt.metadata?.accountID
item.provider.headers = Provider.mergeHeaders(
item.provider.headers,
typeof account === "string" ? { "chatgpt-account-id": account } : undefined,
)
for (const model of item.models.values()) {
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
@@ -210,36 +201,14 @@ export const OpenAIPlugin = define({
draft.enabled = false
return
}
const apiID = draft.modelID ?? draft.id
const match = apiID.match(/^gpt-(\d+\.\d+)/)
if (
!codexAllowed.has(apiID) &&
(codexDisallowed.has(apiID) || !match || Number.parseFloat(match[1]) <= 5.4)
) {
if (!OpenAICodex.eligible(draft.modelID ?? draft.id)) {
draft.enabled = false
return
}
draft.cost = []
if (draft.id.includes("gpt-5.5")) {
draft.limit = { context: 400_000, input: 272_000, output: 128_000 }
}
if (draft.id.includes("gpt-5.6")) {
draft.limit = { context: 500_000, input: 372_000, output: 128_000 }
}
})
}
})
yield* ctx.session.hook("request", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(evt.url)
if (url.origin === "https://api.openai.com") {
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
}
evt.headers.originator = "opencode"
evt.headers["session-id"] = evt.sessionID
}),
)
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
@@ -247,6 +216,21 @@ export const OpenAIPlugin = define({
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/openai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
evt.sdk = mod.createOpenAI(evt.options)
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
if (evt.model.providerID !== Provider.ID.openai) return
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
}),
)
}),
} satisfies PluginInternal.InternalPlugin)
+56 -26
View File
@@ -1,4 +1,4 @@
import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
import { Duration, Effect, Option, Schema, Semaphore } from "effect"
import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
@@ -14,7 +14,7 @@ import { Money } from "@opencode-ai/schema/money"
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options"
import { ConfigV1 } from "../../v1/config/config"
const defaultServer = "https://console.opencode.ai"
const defaultServer = "https://opencode.ai/console"
const clientID = "opencode-cli"
const methodID = Integration.MethodID.make("device")
const RemoteResponse = Schema.Struct({ config: ConfigV1.Info })
@@ -47,15 +47,13 @@ function oauth(http: HttpClient.HttpClient) {
Effect.gen(function* () {
const server = yield* normalizeServer(inputs.server ?? defaultServer)
const device = yield* post(http, `${server}/auth/device/code`, { client_id: clientID }, Device)
const verification = URL.canParse(device.verification_uri_complete)
? new URL(device.verification_uri_complete)
: undefined
if (verification && verification.protocol !== "http:" && verification.protocol !== "https:") {
const verification = new URL(device.verification_uri_complete, new URL(server).origin)
if (verification.protocol !== "http:" && verification.protocol !== "https:") {
return yield* Effect.fail(new Error("Invalid device verification URL: expected HTTP(S)"))
}
return {
mode: "auto" as const,
url: verification?.href ?? `${server}/${device.verification_uri_complete.replace(/^\/+/, "")}`,
url: verification.href,
instructions: `Enter code: ${device.user_code}`,
callback: poll(http, server, device.device_code, Duration.seconds(device.interval)),
}
@@ -97,13 +95,14 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
connected = connection !== undefined
providers = credential
? yield* fetchProviders(http, credential).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
),
)
: undefined
const managed = credential && typeof credential.metadata?.server === "string"
const loaded = managed ? yield* fetchProviders(http, credential) : undefined
if (managed && !loaded) {
return yield* Effect.fail(
new Error("OpenCode Console did not return provider config for the selected organization"),
)
}
providers = loaded
})
yield* ctx.integration.transform((draft) => {
@@ -111,10 +110,13 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
integration.name = "OpenCode"
})
draft.method.update(oauth(http))
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
draft.method.update({
integrationID: "opencode",
method: { type: "key", label: "API key (managed inference service account; not Go)" },
})
})
yield* load()
yield* load().pipe(Effect.orDie)
yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(providers ?? {})) {
catalog.provider.update(providerID, (provider) => {
@@ -189,12 +191,19 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
const unsubscribe = yield* bus.listen((event) => {
if (event.type !== Integration.Event.ConnectionUpdated.type) return Effect.void
const data = Schema.decodeUnknownOption(Integration.Event.ConnectionUpdated.data)(event.data)
if (Option.isNone(data) || data.value.integrationID !== Integration.ID.make("opencode")) return Effect.void
return loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))).pipe(
Effect.timeoutOrElse({
duration: "30 seconds",
orElse: () => Effect.fail(new Error("Timed out loading OpenCode Console provider config")),
}),
Effect.orDie,
)
})
yield* Effect.addFinalizer(() => unsubscribe)
}),
})
@@ -214,6 +223,12 @@ function fetchProviders(http: HttpClient.HttpClient, value: CredentialValue) {
.pipe(
Effect.flatMap((response) => {
if (response.status === 404) return Effect.succeed(undefined)
if (response.status === 403) {
return Effect.fail(new Error("OpenCode Console access is forbidden for the selected organization"))
}
if (response.status < 200 || response.status >= 300) {
return Effect.fail(new Error(`OpenCode Console provider config failed with HTTP ${response.status}`))
}
return HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
Effect.map((remote) => remote.config.provider),
@@ -296,8 +311,22 @@ function credential(http: HttpClient.HttpClient, server: string, token: typeof T
],
{ concurrency: 2 },
)
const org = orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0]
return Credential.OAuth.make({
if (orgs.length === 0) {
return yield* Effect.fail(
new Error(
"Your OpenCode Console account does not belong to an organization. Create or join one at https://opencode.ai/console, then try again.",
),
)
}
if (orgs.length > 1) {
return yield* Effect.fail(
new Error(
"Your OpenCode Console account belongs to multiple organizations. Organization selection is not supported yet; use an account with one organization, then try again.",
),
)
}
const org = orgs[0]
const value = Credential.OAuth.make({
type: "oauth" as const,
methodID,
access: token.access_token,
@@ -307,10 +336,11 @@ function credential(http: HttpClient.HttpClient, server: string, token: typeof T
server,
accountID: user.id,
email: user.email,
orgID: org?.id,
orgName: org?.name,
orgID: org.id,
orgName: org.name,
},
})
return value
})
}
+8 -9
View File
@@ -49,7 +49,7 @@ export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
input: AbsolutePath,
) {
return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe(
return yield* fs.up({ targets: [".git", ".hg"], start: input }).pipe(
Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined),
Effect.catch(() => Effect.succeed(undefined)),
)
@@ -224,7 +224,7 @@ const layer = Layer.effect(
})
const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
const dotHg = yield* fs.up({ targets: [".hg"], start: input, mode: "first" }).pipe(
const dotHg = yield* fs.up({ targets: [".hg"], start: input }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
)
@@ -246,13 +246,12 @@ const layer = Layer.effect(
if (repo) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
const canonical =
repo.gitDirectory === repo.commonDirectory
? repo.worktree
: yield* git.worktree.list(repo).pipe(
Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree),
Effect.catch(() => Effect.succeed(repo.worktree)),
)
const canonical = yield* git.worktree
.list(repo)
.pipe(
Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree),
Effect.catch(() => Effect.succeed(repo.worktree)),
)
return yield* persist({
previous,
id: id ?? ID.global,
+1 -1
View File
@@ -374,7 +374,7 @@ const layer = Layer.effect(
directory: location.directory,
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
workspaceID: location.workspaceID ? Workspace.ID.make(location.workspaceID) : undefined,
title: input.title,
title: input.title ?? `New session - ${new Date(now).toISOString()}`,
agent: input.agent,
model: input.model
? {
+7 -6
View File
@@ -117,20 +117,21 @@ export const preview = Effect.fn("SessionHistory.preview")(function* (
.pipe(Effect.catch((error) => (error instanceof Instructions.InitializationBlocked ? error : Effect.die(error))))
})
/** Returns the session's first user message. */
export const firstUserMessage = Effect.fn("SessionHistory.firstUserMessage")(function* (
/** Returns the session's sole user message, or `undefined` once a second one exists. */
export const firstUserMessageIfOnly = Effect.fn("SessionHistory.firstUserMessageIfOnly")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "user")))
.orderBy(asc(SessionMessageTable.seq))
.get()
.limit(2)
.all()
.pipe(Effect.orDie)
if (!row) return undefined
const message = yield* decodeMessageRow(row).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (rows.length !== 1) return undefined
const message = yield* decodeMessageRow(rows[0]).pipe(Effect.catch(() => Effect.succeed(undefined)))
return message?.type === "user" ? message : undefined
})
+1 -1
View File
@@ -17,7 +17,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
return SessionSchema.Info.make({
id: SessionSchema.ID.make(row.id),
projectID: Project.ID.make(row.project_id),
title: row.title ?? undefined,
title: row.title,
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
fork:
row.fork_session_id && row.fork_boundary
@@ -1,7 +1,6 @@
export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
@@ -38,7 +37,6 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
interface Prepared {
readonly request: LLMRequest
readonly options: StreamOptions
/**
* One request-scoped execution operation. Unknown, hook-removed, and
* step-limit-violating calls fail individually through the same seam.
@@ -164,26 +162,6 @@ export const layer = Layer.effect(
tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined,
})
const options: StreamOptions = {
transform: (request) =>
hooks
.trigger("session", "request", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
...request,
})
.pipe(
Effect.tap((event) =>
Effect.sync(() => {
request.url = event.url
request.headers = event.headers
request.body = event.body
}),
),
Effect.asVoid,
),
}
if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request)
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current)
@@ -212,7 +190,6 @@ export const layer = Layer.effect(
}
return {
request,
options,
executeTool,
stepLimitReached,
}
+2 -9
View File
@@ -43,8 +43,7 @@ type Usage = {
const ForkBatchSize = 500
const forkTitle = (value?: string) => {
if (value === undefined) return
const forkTitle = (value: string) => {
const match = value.match(/^(.+) \(fork #(\d+)\)$/)
if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})`
return `${value} (fork #1)`
@@ -217,7 +216,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
slug: Slug.create(),
directory: parent.directory,
path: parent.path,
title: forkTitle(parent.title ?? undefined),
title: forkTitle(parent.title),
agent: parent.agent,
model: parent.model,
version: parent.version,
@@ -658,12 +657,6 @@ const layer = Layer.effectDiscard(
input: event.data.input,
timeCreated: event.created,
})
yield* db
.update(SessionTable)
.set({ time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
+11 -19
View File
@@ -93,9 +93,10 @@ const layer = Layer.effect(
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const title = yield* SessionTitle.Service
// Title generation is a side effect of a successful step; it must not delay continuation.
// The in-flight set coalesces overlapping steps while title presence records success durably.
const titlesRunning = new Set<SessionSchema.ID>()
// Title generation is a side effect of the first step; it must not delay step continuation.
// Tracked per process so repeated wakes before the second user message arrives don't
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
const titleStarted = new Set<SessionSchema.ID>()
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
/**
* Drains eligible manual compaction and user input until the Session becomes idle.
@@ -124,7 +125,7 @@ const layer = Layer.effect(
let step = 1
while (true) {
const result = yield* runStep(sessionID, promotable, step)
if (step === 1) yield* startTitle(sessionID)
yield* startTitleOnce(sessionID)
yield* runPendingCompaction(sessionID)
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
promotable = "steer"
@@ -278,7 +279,7 @@ const layer = Layer.effect(
// event durably, fork one fiber per local tool call, and hold back a virgin
// context-overflow provider error so settlement may recover it via compaction.
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(prepared.request, prepared.options).pipe(
const providerStream = llm.stream(prepared.request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
@@ -480,20 +481,11 @@ const layer = Layer.effect(
}
})
/** Starts one title request at a time after a successful step makes user input visible. */
const startTitle = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
if (titlesRunning.has(sessionID)) return
titlesRunning.add(sessionID)
forkTitle(
title.generateForFirstPrompt(sessionID).pipe(
Effect.ignore,
Effect.ensuring(
Effect.sync(() => {
titlesRunning.delete(sessionID)
}),
),
),
)
/** Fires title generation once per process after the first step makes a user message visible. */
const startTitleOnce = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
if (titleStarted.has(sessionID)) return
titleStarted.add(sessionID)
forkTitle(title.generateForFirstPrompt(yield* getSession(sessionID)).pipe(Effect.ignore))
})
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
+1 -1
View File
@@ -36,7 +36,7 @@ export const SessionTable = sqliteTable(
slug: text().notNull(),
directory: directoryColumn().notNull(),
path: pathColumn(),
title: text(),
title: text().notNull(),
version: text().notNull(),
share_url: text(),
summary_additions: integer(),
+12 -30
View File
@@ -1,7 +1,7 @@
export * as SessionTitle from "./title"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { Context, Effect, Layer, Stream } from "effect"
import { Agent } from "../agent"
import { Database } from "../database/database"
import { Bus } from "../bus"
@@ -14,10 +14,8 @@ import { SessionModelHeaders } from "./model-headers"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { SessionUsage } from "./usage"
import { SessionStore } from "./store"
const MAX_LENGTH = 100
const titleChanged = Symbol("Session title changed")
type Dependencies = {
readonly app: App.Info
@@ -27,30 +25,24 @@ type Dependencies = {
}
readonly agents: Agent.Interface
readonly models: SessionRunnerModel.Interface
readonly store: SessionStore.Interface
}
export interface Interface {
/** Generates a title from the session's first user message when the session remains untitled. */
readonly generateForFirstPrompt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Generates a title from the session's first user message and renames the session. Runs at most once per session. */
readonly generateForFirstPrompt: (session: SessionSchema.Info) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTitle") {}
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
const isUntitled = (session: SessionSchema.Info) =>
session.title === undefined || session.title === `New session - ${DateTime.formatIso(session.time.created)}`
const make = (dependencies: Dependencies) => {
const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (
db: Database.Interface["db"],
sessionID: SessionSchema.ID,
session: SessionSchema.Info,
) {
const session = yield* dependencies.store.get(sessionID)
if (!session) return
if (session.parentID) return
if (!isUntitled(session)) return
const firstUser = yield* SessionHistory.firstUserMessage(db, session.id)
const firstUser = yield* SessionHistory.firstUserMessageIfOnly(db, session.id)
if (!firstUser) return
const agent = yield* dependencies.agents.get(Agent.ID.make("title"))
if (!agent) return
@@ -104,19 +96,10 @@ const make = (dependencies: Dependencies) => {
.map((line) => line.trim())
.find((line) => line.length > 0)
if (!title) return
const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1
const current = yield* dependencies.store.get(sessionID)
if (!current || !isUntitled(current)) return
yield* dependencies.bus
.publish(
SessionEvent.Renamed,
{
sessionID: session.id,
title: truncate(title),
},
{ commit: (sequence) => (sequence === expectedSequence ? Effect.void : Effect.die(titleChanged)) },
)
.pipe(Effect.catchDefect((defect) => (defect === titleChanged ? Effect.void : Effect.die(defect))))
yield* dependencies.bus.publish(SessionEvent.Renamed, {
sessionID: session.id,
title: truncate(title),
})
})
return { generateForFirstPrompt }
}
@@ -128,12 +111,11 @@ export const layer = Layer.effect(
const llm = yield* LLMClient.Service
const agents = yield* Agent.Service
const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service
const database = yield* Database.Service
const app = yield* App.Metadata
const title = make({ bus, llm, agents, models, store, app })
const title = make({ bus, llm, agents, models, app })
return Service.of({
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
})
}),
)
@@ -141,5 +123,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, SessionStore.node, Database.node, App.node],
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, Database.node, App.node],
})
-296
View File
@@ -1,296 +0,0 @@
export * as ShellParse from "./parse"
import { Effect } from "effect"
import { fileURLToPath } from "url"
import os from "os"
import path from "path"
import type { Node } from "web-tree-sitter"
import { shellParserWasm } from "#shell-parser-wasm"
import { ShellSelect } from "./select"
type Part = { type: string; text: string }
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"])
const ARITY: Record<string, number> = {
cat: 1,
cd: 1,
chmod: 1,
chown: 1,
cp: 1,
echo: 1,
env: 1,
export: 1,
grep: 1,
kill: 1,
killall: 1,
ln: 1,
ls: 1,
mkdir: 1,
mv: 1,
ps: 1,
pwd: 1,
rm: 1,
rmdir: 1,
sleep: 1,
source: 1,
tail: 1,
touch: 1,
unset: 1,
which: 1,
aws: 3,
az: 3,
bazel: 2,
brew: 2,
bun: 2,
"bun run": 3,
"bun x": 3,
cargo: 2,
"cargo add": 3,
"cargo run": 3,
cdk: 2,
cf: 2,
cmake: 2,
composer: 2,
consul: 2,
"consul kv": 3,
crictl: 2,
deno: 2,
"deno task": 3,
doctl: 3,
docker: 2,
"docker builder": 3,
"docker compose": 3,
"docker container": 3,
"docker image": 3,
"docker network": 3,
"docker volume": 3,
eksctl: 2,
"eksctl create": 3,
firebase: 2,
flyctl: 2,
gcloud: 3,
gh: 3,
git: 2,
"git config": 3,
"git remote": 3,
"git stash": 3,
go: 2,
gradle: 2,
helm: 2,
heroku: 2,
hugo: 2,
ip: 2,
"ip addr": 3,
"ip link": 3,
"ip netns": 3,
"ip route": 3,
kind: 2,
"kind create": 3,
kubectl: 2,
"kubectl kustomize": 3,
"kubectl rollout": 3,
kustomize: 2,
make: 2,
mc: 2,
"mc admin": 3,
minikube: 2,
mongosh: 2,
mysql: 2,
mvn: 2,
ng: 2,
npm: 2,
"npm exec": 3,
"npm init": 3,
"npm run": 3,
"npm view": 3,
npx: 2,
nvm: 2,
nx: 2,
openssl: 2,
"openssl req": 3,
"openssl x509": 3,
pip: 2,
pipenv: 2,
pnpm: 2,
"pnpm dlx": 3,
"pnpm exec": 3,
"pnpm run": 3,
poetry: 2,
podman: 2,
"podman container": 3,
"podman image": 3,
psql: 2,
pulumi: 2,
"pulumi stack": 3,
python: 2,
pyenv: 2,
rake: 2,
rbenv: 2,
"redis-cli": 2,
rustup: 2,
serverless: 2,
sfdx: 3,
skaffold: 2,
sls: 2,
sst: 2,
swift: 2,
systemctl: 2,
terraform: 2,
"terraform workspace": 3,
tmux: 2,
turbo: 2,
ufw: 2,
vault: 2,
"vault auth": 3,
"vault kv": 3,
vercel: 2,
volta: 2,
wp: 2,
yarn: 2,
"yarn dlx": 3,
"yarn run": 3,
}
export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) {
const parsers = yield* Effect.promise(load)
const powershell = ShellSelect.ps(shell)
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
if (!tree) return yield* Effect.fail(new Error("Failed to parse shell command"))
return yield* Effect.acquireUseRelease(
Effect.succeed(tree),
(tree) =>
Effect.sync(() =>
tree.rootNode.descendantsOfType("command").reduce(
(result, node) => {
if (!node) return result
const command = parts(node)
const tokens = command.map((part) => part.text)
if (tokens.length === 0) return result
const name = powershell ? tokens[0].toLowerCase() : tokens[0]
if (CWD.has(name)) {
result.directories.push(...directoryArgs(command, powershell, cwd, shell))
return result
}
result.commands.push({
resource: (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim(),
save: `${prefix(tokens).join(" ")} *`,
})
return result
},
{ commands: [] as Array<{ resource: string; save: string }>, directories: [] as string[] },
),
),
(tree) => Effect.sync(() => tree.delete()),
)
})
function parts(node: Node) {
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
const child = node.child(index)
if (!child) return []
if (child.type === "command_elements")
return Array.from({ length: child.childCount }).flatMap((_, itemIndex): Part[] => {
const item = child.child(itemIndex)
if (!item || item.type === "command_argument_sep" || item.type === "redirection") return []
return [{ type: item.type, text: item.text }]
})
if (!["command_name", "command_name_expr", "word", "string", "raw_string", "concatenation"].includes(child.type))
return []
return [{ type: child.type, text: child.text }]
})
}
function directoryArgs(command: Part[], powershell: boolean, cwd: string, shell: string) {
if (!powershell)
return command
.slice(1)
.filter((part) => !part.text.startsWith("-"))
.map((part) => directoryArgument(part.text, powershell, cwd, shell))
.filter((part) => part !== undefined)
const directories: string[] = []
let path = false
for (const part of command.slice(1)) {
if (path) {
const value = directoryArgument(part.text, powershell, cwd, shell)
if (value) directories.push(value)
path = false
continue
}
if (part.type === "command_parameter") {
path = POWERSHELL_PATH_FLAGS.has(part.text.toLowerCase())
continue
}
const value = directoryArgument(part.text, powershell, cwd, shell)
if (value) directories.push(value)
}
return directories
}
function directoryArgument(value: string, powershell: boolean, cwd: string, shell: string) {
const quote = value[0]
const text = (quote === '"' || quote === "'") && value.at(-1) === quote ? value.slice(1, -1) : value
if (!powershell) return expandKnownDirectory(text)
// PowerShell exposes environment variables through $env:NAME and provides these
// automatic directory variables. Expand only values we can determine without executing code.
return expandKnownDirectory(
text
.replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => environment(key) ?? "")
.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => environment(key) ?? "")
.replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => {
if (key.toUpperCase() === "HOME") return os.homedir()
if (key.toUpperCase() === "PWD") return cwd
return path.dirname(shell)
}),
)
}
function expandKnownDirectory(value: string) {
// Unknown shell expressions cannot be resolved safely during permission analysis.
if (value.includes("$") || value.includes("`") || value.startsWith("(")) return
if (value === "~") return os.homedir()
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2))
return value
}
function environment(key: string) {
if (process.platform !== "win32") return process.env[key]
const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase())
return name ? process.env[name] : undefined
}
function prefix(tokens: string[]) {
for (let length = tokens.length; length > 0; length--) {
const arity = ARITY[tokens.slice(0, length).join(" ")]
if (arity !== undefined) return tokens.slice(0, arity)
}
return tokens.slice(0, 1)
}
function resolve(asset: string) {
if (asset.startsWith("file://")) return fileURLToPath(asset)
if (path.isAbsolute(asset)) return asset
return fileURLToPath(new URL(asset, import.meta.url))
}
const load = (() => {
let loading: ReturnType<typeof initialize> | undefined
return () => (loading ??= initialize())
})()
async function initialize() {
const { Parser, Language } = await import("web-tree-sitter")
await Parser.init({ locateFile: () => resolve(shellParserWasm.runtime) })
const [bashLanguage, psLanguage] = await Promise.all([
Language.load(resolve(shellParserWasm.bash)),
Language.load(resolve(shellParserWasm.powershell)),
])
const bash = new Parser()
bash.setLanguage(bashLanguage)
const ps = new Parser()
ps.setLanguage(psLanguage)
return { bash, ps }
}
@@ -1,8 +0,0 @@
// @ts-ignore Bun embeds static file imports when compiling the CLI.
import runtime from "web-tree-sitter/tree-sitter.wasm" with { type: "file" }
// @ts-ignore Bun embeds static file imports when compiling the CLI.
import bash from "tree-sitter-bash/tree-sitter-bash.wasm" with { type: "file" }
// @ts-ignore Bun embeds static file imports when compiling the CLI.
import powershell from "tree-sitter-powershell/tree-sitter-powershell.wasm" with { type: "file" }
export const shellParserWasm = { runtime, bash, powershell }
@@ -1,12 +0,0 @@
import { createRequire } from "node:module"
const require = createRequire(import.meta.url)
export const shellParserWasm = {
runtime: process.env.OPENCODE_TREE_SITTER_WASM_PATH ?? require.resolve("web-tree-sitter/tree-sitter.wasm"),
bash:
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH ?? require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"),
powershell:
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH ??
require.resolve("tree-sitter-powershell/tree-sitter-powershell.wasm"),
}
+23 -23
View File
@@ -1,6 +1,5 @@
export * as ShellTool from "./shell"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
@@ -12,7 +11,6 @@ import { PluginRuntime } from "../../plugin/runtime"
import { NonNegativeInt } from "../../schema"
import { SessionSchema } from "../../session/schema"
import { Shell } from "../../shell"
import { ShellParse } from "../../shell/parse"
export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
@@ -77,6 +75,18 @@ const modelOutput = (output: Output): string | undefined => {
return `Command exited with code ${output.exit}.`
}
/**
* Minimal core shell boundary. Keep parity debt visible without pulling the
* legacy shell runtime into core.
*/
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
// TODO: Port BashArity reusable command-prefix approvals.
// TODO: Add plugin shell.env environment augmentation once plugin hooks exist.
// TODO: Persist job status and define restart recovery before exposing remote observation.
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
export const Plugin = {
id: "opencode.tool.shell",
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
@@ -148,34 +158,24 @@ export const Plugin = {
(invocation) =>
Effect.gen(function* () {
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.canonical)
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({ path: path.resolve(target.canonical, directory), kind: "directory" }),
)
invocation.cwd = target.canonical
finalTimeout = invocation.timeout
const external = [target, ...directories]
.map((item) => item.externalDirectory)
.filter((item) => item !== undefined)
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
if (external.length > 0)
const external = target.externalDirectory
if (external)
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [invocation.command],
save: [invocation.command],
sessionID: context.sessionID,
agent: context.agent,
source,
})
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
}),
-106
View File
@@ -1,106 +0,0 @@
import { describe, expect, test } from "bun:test"
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
describe("AISDKNative", () => {
test("maps every Google thinking setting", () => {
expect(
AISDKNative.map("@ai-sdk/google", {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
serviceTier: "flex",
thinkingConfig: {
thinkingBudget: 0,
includeThoughts: false,
thinkingLevel: "high",
unknown: true,
},
}),
).toEqual({
package: "@opencode-ai/ai/providers/google",
settings: {
providerOptions: {
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
serviceTier: "flex",
thinkingConfig: {
thinkingBudget: 0,
includeThoughts: false,
thinkingLevel: "high",
},
},
},
},
})
})
test("maps Google thinking settings independently", () => {
for (const thinkingConfig of [{ thinkingBudget: -1 }, { includeThoughts: true }, { thinkingLevel: "medium" }]) {
expect(AISDKNative.map("@ai-sdk/google", { thinkingConfig })).toMatchObject({
settings: { providerOptions: { gemini: { thinkingConfig } } },
})
}
})
test("maps Google request options without thinking settings", () => {
expect(
AISDKNative.map("@ai-sdk/google", {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
serviceTier: "future-tier",
}),
).toMatchObject({
settings: {
providerOptions: {
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
serviceTier: "future-tier",
},
},
},
})
})
test("maps supported xAI settings", () => {
expect(
AISDKNative.map("@ai-sdk/xai", {
apiKey: "secret",
baseURL: "https://xai.example/v1",
reasoningEffort: "custom",
store: true,
promptCacheKey: "cache-key",
}),
).toEqual({
package: "@opencode-ai/ai/providers/xai",
settings: {
apiKey: "secret",
baseURL: "https://xai.example/v1",
providerOptions: {
xai: {
reasoningEffort: "custom",
store: true,
promptCacheKey: "cache-key",
},
},
},
})
})
test("omits invalid and unsupported xAI settings", () => {
expect(
AISDKNative.map("@ai-sdk/xai", {
reasoningEffort: 10,
store: "yes",
include: ["unknown"],
logprobs: true,
topLogprobs: 8,
previousResponseId: "response-id",
searchParameters: { mode: "auto" },
}),
).toEqual({
package: "@opencode-ai/ai/providers/xai",
settings: {},
})
})
})
@@ -26,7 +26,6 @@ import timeSuspendedMigration from "@opencode-ai/core/database/migration/2026070
import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync"
import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migration/20260722011141_delete_tool_progress_events"
import canonicalToolResultsMigration from "@opencode-ai/core/database/migration/20260722170000_canonical_tool_results"
import optionalSessionTitleMigration from "@opencode-ai/core/database/migration/20260730195856_optional_session_title"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
@@ -400,40 +399,6 @@ describe("DatabaseMigration", () => {
).rejects.toThrow("Database is not empty and has no session table")
})
test("makes session titles nullable without deleting dependent rows", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`PRAGMA foreign_keys = ON`)
yield* db.run(sql`
CREATE TABLE session (
id text PRIMARY KEY,
title text NOT NULL
)
`)
yield* db.run(sql`
CREATE TABLE message (
id text PRIMARY KEY,
session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE
)
`)
yield* db.run(sql`INSERT INTO session VALUES ('ses_existing', 'Existing title')`)
yield* db.run(sql`INSERT INTO message VALUES ('msg_existing', 'ses_existing')`)
yield* DatabaseMigration.applyOnly(db, [optionalSessionTitleMigration])
expect(yield* db.get(sql`SELECT title FROM session WHERE id = 'ses_existing'`)).toEqual({
title: "Existing title",
})
expect(yield* db.get(sql`SELECT id FROM message WHERE id = 'msg_existing'`)).toEqual({ id: "msg_existing" })
expect(
yield* db.get<{ notnull: number }>(sql`SELECT "notnull" FROM pragma_table_info('session') WHERE name = 'title'`),
).toEqual({ notnull: 0 })
expect(yield* db.get<{ foreign_keys: number }>(sql`PRAGMA foreign_keys`)).toEqual({ foreign_keys: 1 })
}),
)
})
test("backfills existing Context Epoch rows to the build agent", async () => {
await run(
Effect.gen(function* () {
@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test"
import { Effect, FileSystem, Layer } from "effect"
import { Effect, FileSystem } from "effect"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -267,33 +267,6 @@ describe("FSUtil", () => {
expect(result).toContain(path.join(tmp, "b.txt"))
}),
)
it(
"stops at the first match when requested",
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
yield* filesys.writeFileString(path.join(tmp, "marker"), "root")
const child = path.join(tmp, "sub")
yield* filesys.makeDirectory(child)
yield* filesys.writeFileString(path.join(child, "marker"), "child")
const checked: string[] = []
const instrumented = FileSystem.FileSystem.of({
...filesys,
exists: (target) => Effect.sync(() => checked.push(target)).pipe(Effect.andThen(filesys.exists(target))),
})
const search = yield* FSUtil.Service.pipe(
Effect.provide(
FSUtil.layer.pipe(Layer.fresh, Layer.provide(Layer.succeed(FileSystem.FileSystem, instrumented))),
),
)
expect(yield* search.up({ targets: ["marker", "other"], start: child, mode: "first" })).toEqual([
path.join(child, "marker"),
])
expect(checked).toEqual([path.join(child, "marker")])
}),
)
})
describe("glob", () => {
+1 -1
View File
@@ -15,7 +15,7 @@ import { testEffect } from "./lib/effect"
const selected = Info.make({
...Info.default(Provider.ID.make("test-provider"), ID.make("gemini")),
package: Provider.aisdk("@ai-sdk/mistral"),
package: Provider.aisdk("@ai-sdk/google"),
})
const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
-37
View File
@@ -1,7 +1,6 @@
import path from "node:path"
import { describe, expect, test } from "bun:test"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
@@ -560,42 +559,6 @@ test("lists, reads, and reports MCP resource changes", async () => {
)
})
test("does not reconnect an SSE stream after a JSON-RPC error response", async () => {
let requests = 0
const transport = new StreamableHTTPClientTransport(new URL("http://mcp.invalid"), {
fetch: async () => {
requests += 1
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("id: prime\nretry: 1\ndata:\n\n"))
controller.enqueue(
new TextEncoder().encode(
'id: error\ndata: {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}\n\n',
),
)
controller.close()
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
)
},
reconnectionOptions: {
initialReconnectionDelay: 1,
maxReconnectionDelay: 1,
reconnectionDelayGrowFactor: 1,
maxRetries: 2,
},
})
await transport.start()
await transport.send({ jsonrpc: "2.0", method: "resources/list", id: 1 })
await Bun.sleep(25)
await transport.close()
expect(requests).toBe(1)
})
test("skips MCP resource requests when the capability is absent", async () => {
await Effect.runPromise(
Effect.scoped(
+76 -101
View File
@@ -307,12 +307,12 @@ describe("ModelResolver", () => {
}),
)
it.effect("applies plugin-projected OpenAI endpoint and headers", () =>
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/openai", {
settings: { baseURL: "https://chatgpt.com/backend-api/codex" },
headers: { "chatgpt-account-id": "acct_123" },
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
body: {},
}),
Credential.OAuth.make({
@@ -337,8 +337,37 @@ describe("ModelResolver", () => {
id: "openai-responses",
endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
})
expect(resolved.route.defaults.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(headers.authorization).toBe("Bearer chatgpt-token")
expect(headers["chatgpt-account-id"]).toBe("acct_123")
}),
)
it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/openai", {
settings: { baseURL: "https://openai.example/v1" },
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("chatgpt-browser"),
access: "chatgpt-token",
refresh: "refresh",
expires: Date.now() + 60_000,
metadata: { accountID: "acct_123" },
}),
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://chatgpt.com/backend-api/codex/responses",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
expect(headers.authorization).toBe("Bearer chatgpt-token")
expect(headers["chatgpt-account-id"]).toBe("acct_123")
}),
)
@@ -378,6 +407,37 @@ describe("ModelResolver", () => {
}),
)
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
body: {},
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("chatgpt-headless"),
access: "chatgpt-token",
refresh: "refresh",
expires: Date.now() + 60_000,
}),
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth.apply({
request,
method: "POST",
url: "https://chatgpt.com/backend-api/codex/responses",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
expect(headers.authorization).toBe("Bearer chatgpt-token")
expect(headers["chatgpt-account-id"]).toBeUndefined()
}),
)
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
@@ -486,91 +546,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("routes supported AISDK catalog packages through native provider packages", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
const packages = [
[
"@ai-sdk/google",
"@opencode-ai/ai/providers/google",
{ thinkingConfig: { thinkingLevel: "high" } },
{ gemini: { thinkingConfig: { thinkingLevel: "high" } } },
],
[
"@openrouter/ai-sdk-provider",
"@opencode-ai/ai/providers/openrouter",
{ reasoning: { effort: "high" } },
{ openrouter: { reasoning: { effort: "high" } } },
],
[
"@ai-sdk/xai",
"@opencode-ai/ai/providers/xai",
{ reasoningEffort: "high" },
{ xai: { reasoningEffort: "high" } },
],
] as const
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) =>
ModelResolver.fromCatalogModel(
model(Provider.aisdk(catalogPackage), {
modelID: "api-model",
settings: { baseURL: "https://provider.example/v1", ...sourceOptions },
headers: { "x-provider": "header" },
body: { custom: true },
}),
Credential.Key.make({ type: "key", key: "secret" }),
{
loadPackage: (specifier) => {
expect(specifier).toBe(nativePackage)
return Effect.succeed({
model: (modelID, settings) => {
expect(modelID).toBe("api-model")
expect(settings).toMatchObject({
apiKey: "secret",
baseURL: "https://provider.example/v1",
headers: { "x-provider": "header" },
body: { custom: true },
limits: { context: 100, output: 20 },
providerOptions,
})
return Model.make({ id: modelID, provider: "native-provider", route: native.route })
},
})
},
loadAISDK: () => Effect.die("AI SDK loader should not be called"),
},
),
)
}),
)
it.effect("loads supported AISDK catalog packages as native routes", () =>
Effect.gen(function* () {
const google = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), { settings: { thinkingConfig: { thinkingBudget: 1_024 } } }),
)
const openrouter = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
settings: { reasoning: { effort: "high" } },
}),
)
const xai = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }),
)
expect(google.route.id).toBe("gemini")
expect(google.route.defaults.providerOptions).toEqual({
gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
})
expect(openrouter.route.id).toBe("openrouter")
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { reasoning: { effort: "high" } } })
expect(xai.route.id).toBe("openai-responses")
expect(xai.route.defaults.providerOptions).toEqual({
xai: { reasoningEffort: "high", store: false },
})
}),
)
it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(
@@ -579,8 +554,8 @@ describe("ModelResolver", () => {
}),
)
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
modelID: "mistral-api-model",
model(Provider.aisdk("@ai-sdk/google"), {
modelID: "gemini-api-model",
settings: { project: "test" },
headers: { "x-aisdk": "header" },
body: { custom: true },
@@ -591,9 +566,9 @@ describe("ModelResolver", () => {
Effect.sync(() => {
expect(runtime).toMatchObject({
id: "test-model",
modelID: "mistral-api-model",
modelID: "gemini-api-model",
providerID: "test-provider",
package: Provider.aisdk("@ai-sdk/mistral"),
package: Provider.aisdk("@ai-sdk/google"),
settings: { project: "test", apiKey: "fallback-secret" },
headers: { "x-aisdk": "header" },
body: { custom: true },
@@ -607,15 +582,15 @@ describe("ModelResolver", () => {
},
)
expect(resolved).toMatchObject({ id: "mistral-api-model", provider: "test-provider" })
expect(resolved).toMatchObject({ id: "gemini-api-model", provider: "test-provider" })
}),
)
it.effect("rejects AISDK packages without an available loader", () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { baseURL: "https://mistral.example/v1" },
model(Provider.aisdk("@ai-sdk/google"), {
settings: { baseURL: "https://google.example/v1" },
}),
).pipe(Effect.flip)
@@ -623,9 +598,9 @@ describe("ModelResolver", () => {
_tag: "SessionRunnerModel.UnsupportedPackageError",
providerID: "test-provider",
modelID: "test-model",
package: "aisdk:@ai-sdk/mistral",
package: "aisdk:@ai-sdk/google",
})
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/mistral")
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/google")
}),
)
@@ -637,8 +612,8 @@ describe("ModelResolver", () => {
}),
)
yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { apiKey: "", baseURL: "https://mistral.example/v1" },
model(Provider.aisdk("@ai-sdk/google"), {
settings: { apiKey: "", baseURL: "https://google.example/v1" },
}),
undefined,
{
+140 -67
View File
@@ -1,7 +1,7 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { Money } from "@opencode-ai/schema/money"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
@@ -9,7 +9,6 @@ import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
@@ -19,6 +18,7 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
const integrations = yield* Integration.Service
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
@@ -29,6 +29,19 @@ function required<T>(value: T | undefined): T {
return value
}
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () {
@@ -48,6 +61,104 @@ describe("OpenAIPlugin", () => {
}),
)
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-openai"), Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/openai",
options: { name: "custom-openai", apiKey: "test" },
})
expect(result.sdk?.responses("gpt-5").provider).toBe("custom-openai.responses")
}),
)
it.effect("ignores non-OpenAI SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/openai-compatible",
options: { name: "openai" },
})
expect(result.sdk).toBeUndefined()
}),
)
it.effect("uses the Responses API for language models", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: Model.Info.make({
...Model.Info.default(Provider.ID.openai, Model.ID.make("alias")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
expect(calls).toEqual(["responses:gpt-5"])
expect(result.language).toBeDefined()
}),
)
it.effect("ignores non-OpenAI providers", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: Model.Info.make({
...Model.Info.default(Provider.ID.anthropic, Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
expect(calls).toEqual([])
expect(result.language).toBeUndefined()
}),
)
it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const item = Provider.Info.make({
...Provider.Info.empty(Provider.ID.openai),
package: Provider.aisdk("@ai-sdk/openai"),
})
catalog.provider.update(item.id, (draft) => {
draft.package = item.package
})
catalog.model.update(item.id, Model.ID.make("gpt-5"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5-chat-latest"), () => {})
})
yield* addPlugin()
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(true)
expect(
required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5-chat-latest"))).enabled,
).toBe(false)
}),
)
it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -61,7 +172,6 @@ describe("OpenAIPlugin", () => {
draft.package = item.package
})
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
model.cost = [
{
input: Money.USDPerMillionTokens.make(1),
@@ -79,9 +189,7 @@ describe("OpenAIPlugin", () => {
model.body = { reasoning: { mode: "pro" } }
})
catalog.model.update(item.id, Model.ID.make("gpt-5.6"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5.6-sol"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
})
catalog.model.update(item.id, Model.ID.make("gpt-5.6-sol"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
})
yield* credentials.create({
@@ -97,47 +205,8 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const custom = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }),
url: "https://custom.example/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const proxy = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://proxy.example/v1/responses?region=us",
method: "POST",
headers: {},
body: "{}",
})
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(custom.headers).toEqual({})
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
expect(eligible.cost).toEqual([])
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
false,
@@ -146,9 +215,9 @@ describe("OpenAIPlugin", () => {
false,
)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
expect(gpt56.enabled).toBe(true)
expect(gpt56.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"))).enabled).toBe(
true,
)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
}),
)
@@ -165,9 +234,7 @@ describe("OpenAIPlugin", () => {
catalog.provider.update(item.id, (draft) => {
draft.package = item.package
})
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
})
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
})
yield* credentials.create({
@@ -176,23 +243,29 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(request.headers).toEqual({})
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))).enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const item = Provider.Info.make({
...Provider.Info.empty(Provider.ID.make("custom-openai")),
package: Provider.aisdk("test-provider"),
})
catalog.provider.update(item.id, (draft) => {
draft.package = item.package
})
catalog.model.update(item.id, Model.ID.make("gpt-5-chat-latest"), () => {})
})
yield* addPlugin()
expect(
required(yield* catalog.model.get(Provider.ID.make("custom-openai"), Model.ID.make("gpt-5-chat-latest")))
.enabled,
).toBe(true)
}),
)
})
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Bus } from "@opencode-ai/core/bus"
@@ -87,25 +88,102 @@ describe("OpencodePlugin", () => {
type: "oauth",
label: "OpenCode Console account",
},
{ type: "key", label: "API key (service account)" },
{ type: "key", label: "API key (managed inference service account; not Go)" },
])
}),
)
it.effect("uses the canonical OpenCode Console server by default", () =>
Effect.gen(function* () {
const requests: string[] = []
const http = HttpClient.make((request) => {
requests.push(request.url)
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({
device_code: "device",
user_code: "user",
verification_uri_complete: "/console/verify",
expires_in: 60,
interval: 60,
}),
),
)
})
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const bus = yield* Bus.Service
const integration = yield* Integration.Service
yield* OpencodePlugin.effect(host).pipe(
Effect.provideService(Bus.Service, bus),
Effect.provideService(Integration.Service, integration),
Effect.provideService(HttpClient.HttpClient, http),
)
const attempt = yield* integration.oauth.connect({
integrationID: Integration.ID.make("opencode"),
methodID: Integration.MethodID.make("device"),
inputs: {},
})
yield* integration.oauth.cancel({ integrationID: Integration.ID.make("opencode"), attemptID: attempt.attemptID })
expect(requests).toEqual(["https://opencode.ai/console/auth/device/code"])
expect(attempt.url).toBe("https://opencode.ai/console/verify")
}),
)
it.effect("keeps an absolute verification URL", () =>
Effect.gen(function* () {
const http = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({
device_code: "device",
user_code: "user",
verification_uri_complete: "https://login.example.com/device/verify",
expires_in: 60,
interval: 60,
}),
),
),
)
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const bus = yield* Bus.Service
const integration = yield* Integration.Service
yield* OpencodePlugin.effect(host).pipe(
Effect.provideService(Bus.Service, bus),
Effect.provideService(Integration.Service, integration),
Effect.provideService(HttpClient.HttpClient, http),
)
const attempt = yield* integration.oauth.connect({
integrationID: Integration.ID.make("opencode"),
methodID: Integration.MethodID.make("device"),
inputs: {},
})
yield* integration.oauth.cancel({ integrationID: Integration.ID.make("opencode"), attemptID: attempt.attemptID })
expect(attempt.url).toBe("https://login.example.com/device/verify")
}),
)
it.live("uses a canonical custom server throughout device authorization", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: string[] = []
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const server = Bun.serve({
port: 0,
fetch: (request) => {
fetch: async (request) => {
const url = new URL(request.url)
requests.push(`${request.method} ${url.pathname}`)
if (url.pathname.endsWith("/auth/device/code")) {
return Response.json({
device_code: "device",
user_code: "user",
verification_uri_complete: `${url.origin}/verify`,
verification_uri_complete: "/console/verify",
expires_in: 60,
interval: 0,
})
@@ -115,12 +193,17 @@ describe("OpencodePlugin", () => {
}
if (url.pathname.endsWith("/api/user")) return Response.json({ id: "user", email: "user@example.com" })
if (url.pathname.endsWith("/api/orgs")) return Response.json([{ id: "org", name: "Org" }])
if (url.pathname.endsWith("/api/config")) {
requested.resolve()
await release.promise
return Response.json({ config: { enterprise: { url: url.origin }, provider: {} } })
}
return new Response("Not found", { status: 404 })
},
})
return { requests, server }
return { release, requested, requests, server }
}),
({ requests, server }) =>
({ release, requested, requests, server }) =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
@@ -130,7 +213,12 @@ describe("OpencodePlugin", () => {
methodID: Integration.MethodID.make("device"),
inputs: { server: `${server.url.origin}/console///?ignored=true#ignored` },
})
expect(attempt.url).toBe(`${server.url.origin}/verify`)
expect(attempt.url).toBe(`${server.url.origin}/console/verify`)
yield* Effect.promise(() => requested.promise)
expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toMatchObject({
status: "pending",
})
release.resolve()
yield* eventually(
integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }),
(status) => status.status === "complete",
@@ -140,11 +228,166 @@ describe("OpencodePlugin", () => {
expect(requests).toContain("POST /console/auth/device/token")
expect(requests).toContain("GET /console/api/user")
expect(requests).toContain("GET /console/api/orgs")
expect(requests).toContain("GET /console/api/config")
expect((yield* (yield* Credential.Service).list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({
metadata: { server: `${server.url.origin}/console` },
metadata: { server: `${server.url.origin}/console`, orgID: "org", orgName: "Org" },
})
}),
({ server }) => Effect.promise(() => server.stop(true)),
({ release, server }) =>
Effect.promise(() => {
release.resolve()
return server.stop(true)
}),
),
)
it.live("rejects device login without an organization", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: (request) => {
const url = new URL(request.url)
if (url.pathname === "/auth/device/code") {
return Response.json({
device_code: "device",
user_code: "user",
verification_uri_complete: `${url.origin}/verify`,
expires_in: 60,
interval: 0,
})
}
if (url.pathname === "/auth/device/token") {
return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 600 })
}
if (url.pathname === "/api/user") return Response.json({ id: "user", email: "user@example.com" })
if (url.pathname === "/api/orgs") return Response.json([])
return new Response("Not found", { status: 404 })
},
}),
),
(server) =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make("opencode")
const attempt = yield* integrations.oauth.connect({
integrationID,
methodID: Integration.MethodID.make("device"),
inputs: { server: server.url.origin },
})
const status = yield* eventually(
integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }),
(value) => value.status !== "pending",
)
expect(status).toMatchObject({ status: "failed" })
if (status.status === "failed") expect(status.message).toContain("does not belong to an organization")
expect(yield* (yield* Credential.Service).list(integrationID)).toEqual([])
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.live("rejects device login with multiple organizations", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: (request) => {
const url = new URL(request.url)
if (url.pathname === "/auth/device/code") {
return Response.json({
device_code: "device",
user_code: "user",
verification_uri_complete: `${url.origin}/verify`,
expires_in: 60,
interval: 0,
})
}
if (url.pathname === "/auth/device/token") {
return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 600 })
}
if (url.pathname === "/api/user") return Response.json({ id: "user", email: "user@example.com" })
if (url.pathname === "/api/orgs") {
return Response.json([
{ id: "org-b", name: "Beta" },
{ id: "org-a", name: "Alpha" },
])
}
return new Response("Not found", { status: 404 })
},
}),
),
(server) =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make("opencode")
const attempt = yield* integrations.oauth.connect({
integrationID,
methodID: Integration.MethodID.make("device"),
inputs: { server: server.url.origin },
})
const status = yield* eventually(
integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }),
(value) => value.status !== "pending",
)
expect(status).toMatchObject({ status: "failed" })
if (status.status === "failed") expect(status.message).toContain("multiple organizations")
expect(yield* (yield* Credential.Service).list(integrationID)).toEqual([])
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.live("does not complete device login before provider config loads", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: (request) => {
const url = new URL(request.url)
if (url.pathname === "/auth/device/code") {
return Response.json({
device_code: "device",
user_code: "user",
verification_uri_complete: `${url.origin}/verify`,
expires_in: 60,
interval: 0,
})
}
if (url.pathname === "/auth/device/token") {
return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 600 })
}
if (url.pathname === "/api/user") return Response.json({ id: "user", email: "user@example.com" })
if (url.pathname === "/api/orgs") return Response.json([{ id: "org", name: "Org" }])
if (url.pathname === "/api/config") return new Response("Forbidden", { status: 403 })
return new Response("Not found", { status: 404 })
},
}),
),
(server) =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make("opencode")
const attempt = yield* integrations.oauth.connect({
integrationID,
methodID: Integration.MethodID.make("device"),
inputs: { server: server.url.origin },
})
const status = yield* eventually(
integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }),
(value) => value.status !== "pending",
)
expect(status).toMatchObject({ status: "failed" })
if (status.status === "failed") expect(status.message).toContain("forbidden for the selected organization")
expect(yield* (yield* Credential.Service).list(integrationID)).toEqual([])
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
-1
View File
@@ -143,7 +143,6 @@ describe("Project.resolve", () => {
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.canonical).toBe(result.directory)
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
}),
-62
View File
@@ -71,27 +71,6 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
}
describe("Session.create", () => {
it.effect("persists a missing title until one is generated or supplied", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const { db } = yield* Database.Service
const created = yield* session.create({ location })
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, created.id)).get().pipe(Effect.orDie)
const event = yield* db
.select({ data: EventTable.data })
.from(EventTable)
.where(eq(EventTable.aggregate_id, created.id))
.get()
.pipe(Effect.orDie)
expect(created.title).toBeUndefined()
expect(row?.title).toBeNull()
expect(event?.data).not.toHaveProperty("info.title")
expect((yield* session.create({ location, title: "Explicit title" })).title).toBe("Explicit title")
}),
)
it.effect("creates a fresh projected session when the ID is omitted", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -192,30 +171,6 @@ describe("Session.create", () => {
}),
)
it.effect("orders sessions by their latest prompt", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const { db } = yield* Database.Service
const active = yield* session.create({ location, title: "active" })
const newer = yield* session.create({ location, title: "newer" })
yield* db
.update(SessionTable)
.set({ time_created: -2, time_updated: -2 })
.where(eq(SessionTable.id, active.id))
.run()
yield* db
.update(SessionTable)
.set({ time_created: -1, time_updated: -1 })
.where(eq(SessionTable.id, newer.id))
.run()
yield* session.prompt({ sessionID: active.id, text: "continue", resume: false })
expect((yield* session.list()).data.map((item) => item.id)).toEqual([active.id, newer.id])
}),
)
it.effect("filters direct child sessions by parent ID", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -317,23 +272,6 @@ describe("Session.create", () => {
}),
)
it.effect("keeps a fork untitled when its parent is untitled", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location })
yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
yield* SessionPending.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, forked.id)).get().pipe(Effect.orDie)
expect(forked.title).toBeUndefined()
expect(row?.title).toBeNull()
}),
)
it.effect("rejects forking an empty session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
-53
View File
@@ -796,59 +796,6 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
})
describe("SessionRunnerLLM", () => {
it.effect("retries title generation from the first prompt after execution and title failures", () =>
Effect.gen(function* () {
const session = yield* setup
const agents = yield* Agent.Service
const { db } = yield* Database.Service
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "Generate a title."
}),
)
yield* admit(session, "First prompt")
yield* TestLLM.push(Stream.fail(invalidRequest()))
expect((yield* session.resume(sessionID).pipe(Effect.exit))._tag).toBe("Failure")
yield* admit(session, "Second prompt")
const titleFailed = yield* Deferred.make<void>()
yield* TestLLM.push(
TestLLM.text("Recovered", "text-recovered"),
Stream.make(LLMEvent.providerError({ message: "Title provider unavailable" })).pipe(
Stream.ensuring(Deferred.succeed(titleFailed, undefined)),
),
)
yield* session.resume(sessionID)
yield* Deferred.await(titleFailed)
yield* Effect.yieldNow
expect((yield* session.get(sessionID)).title).toBeUndefined()
const bus = yield* Bus.Service
const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.take(1),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
)
yield* admit(session, "Third prompt")
yield* TestLLM.push(
TestLLM.text("Recovered again", "text-recovered-again"),
TestLLM.text("Generated title", "text-title"),
)
yield* session.resume(sessionID)
yield* Fiber.join(renamed)
expect(requests).toHaveLength(5)
expect(requests[2]?.messages).toContainEqual(Message.user("First prompt"))
expect(requests[4]?.messages).toContainEqual(Message.user("First prompt"))
expect((yield* session.get(sessionID)).title).toBe("Generated title")
}),
)
it.effect("applies session context hooks without exposing unavailable tools", () =>
Effect.gen(function* () {
const session = yield* setup
+44 -148
View File
@@ -20,7 +20,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import { Effect, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
let requests: LLMRequest[] = []
@@ -39,30 +39,27 @@ const cost = [
},
},
]
const successfulTitle = () =>
Stream.make(
LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }),
LLMEvent.stepFinish({
index: 0,
reason: { normalized: "stop" },
usage: {
inputTokens: 15,
outputTokens: 6,
nonCachedInputTokens: 10,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
reasoningTokens: 2,
},
}),
LLMEvent.finish({
reason: { normalized: "stop" },
}),
)
let titleStream: () => Stream.Stream<LLMEvent> = successfulTitle
const client = Layer.mock(LLMClient.Service)({
stream: (request: LLMRequest) => {
requests.push(request)
return titleStream()
return Stream.make(
LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }),
LLMEvent.stepFinish({
index: 0,
reason: { normalized: "stop" },
usage: {
inputTokens: 15,
outputTokens: 6,
nonCachedInputTokens: 10,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
reasoningTokens: 2,
},
}),
LLMEvent.finish({
reason: { normalized: "stop" },
}),
)
},
generate: () => Effect.die("unused"),
})
@@ -92,7 +89,7 @@ const it = testEffect(
),
)
const insertSession = (id: Session.ID, title?: string, created?: number) =>
const insertSession = (id: Session.ID) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
@@ -108,8 +105,7 @@ const insertSession = (id: Session.ID, title?: string, created?: number) =>
project_id: Project.ID.global,
slug: id,
directory: "/project",
title,
time_created: created,
title: "New session - fake",
version: "test",
})
.onConflictDoNothing()
@@ -135,7 +131,6 @@ const prompt = (sessionID: Session.ID, text: string) =>
it.effect("generates a title from the sole user message and renames the session", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
@@ -149,8 +144,11 @@ it.effect("generates a title from the sole user message and renames the session"
yield* prompt(sessionID, "Help me debug the failing build")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(sessionID)
yield* title.generateForFirstPrompt(session)
expect(requests).toHaveLength(1)
expect(requests[0]?.http?.headers).toEqual({
@@ -169,10 +167,9 @@ it.effect("generates a title from the sole user message and renames the session"
}),
)
it.effect("generates from the first user message after later messages exist", () =>
it.effect("does not generate once a second user message exists", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
@@ -187,46 +184,21 @@ it.effect("generates from the first user message after later messages exist", ()
yield* prompt(sessionID, "Second message")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(sessionID)
yield* title.generateForFirstPrompt(session)
expect(requests).toHaveLength(1)
expect(JSON.stringify(requests[0]?.messages)).toContain("First message")
expect(JSON.stringify(requests[0]?.messages)).not.toContain("Second message")
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
}),
)
it.effect("retries a legacy persisted fallback title", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_legacy")
const created = Date.parse("2026-07-30T18:45:03.662Z")
yield* insertSession(sessionID, "New session - 2026-07-30T18:45:03.662Z", created)
yield* prompt(sessionID, "Retry the legacy title")
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(sessionID)
const store = yield* SessionStore.Service
expect(requests).toHaveLength(1)
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
expect(requests).toHaveLength(0)
const untouched = yield* store.get(sessionID)
expect(untouched?.title).toBe("New session - fake")
}),
)
it.effect("does not generate for a child session", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
@@ -251,6 +223,7 @@ it.effect("does not generate for a child session", () =>
parent_id: Session.ID.make("ses_title_parent"),
slug: sessionID,
directory: "/project",
title: "Child session - fake",
version: "test",
})
.onConflictDoNothing()
@@ -258,8 +231,12 @@ it.effect("does not generate for a child session", () =>
.pipe(Effect.orDie)
yield* prompt(sessionID, "Do this subtask")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(sessionID)
yield* title.generateForFirstPrompt(session)
expect(requests).toHaveLength(0)
}),
@@ -268,100 +245,19 @@ it.effect("does not generate for a child session", () =>
it.effect("does not generate when the title agent is removed", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const sessionID = Session.ID.make("ses_title_no_agent")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(sessionID)
yield* title.generateForFirstPrompt(session)
expect(requests).toHaveLength(0)
const untouched = yield* store.get(sessionID)
expect(untouched?.title).toBeUndefined()
}),
)
it.effect("does not overwrite an explicit title", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const sessionID = Session.ID.make("ses_title_explicit")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
const events = yield* Bus.Service
yield* events.publish(SessionEvent.Renamed, { sessionID, title: "New session - 2099-01-01T00:00:00.000Z" })
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(sessionID)
const store = yield* SessionStore.Service
expect(requests).toHaveLength(0)
expect((yield* store.get(sessionID))?.title).toBe("New session - 2099-01-01T00:00:00.000Z")
}),
)
it.effect("retries after a failed title request", () =>
Effect.gen(function* () {
requests = []
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_retry")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Retry this title")
const title = yield* SessionTitle.Service
titleStream = () => Stream.make(LLMEvent.providerError({ message: "Provider unavailable" }))
yield* title.generateForFirstPrompt(sessionID)
titleStream = successfulTitle
yield* title.generateForFirstPrompt(sessionID)
const store = yield* SessionStore.Service
expect(requests).toHaveLength(2)
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
}),
)
it.effect("preserves a manual rename completed while generation is in flight", () =>
Effect.gen(function* () {
requests = []
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_manual_rename")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Generate this title")
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
titleStream = () =>
Stream.unwrap(
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as(successfulTitle()),
),
)
const title = yield* SessionTitle.Service
const fiber = yield* title.generateForFirstPrompt(sessionID).pipe(Effect.forkScoped)
yield* Deferred.await(started)
const events = yield* Bus.Service
yield* events.publish(SessionEvent.Renamed, { sessionID, title: "Manual title" })
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(fiber)
const store = yield* SessionStore.Service
expect(requests).toHaveLength(1)
expect((yield* store.get(sessionID))?.title).toBe("Manual title")
expect(untouched?.title).toBe("New session - fake")
}),
)
-55
View File
@@ -1,55 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import os from "os"
import path from "path"
import { ShellParse } from "@opencode-ai/core/shell/parse"
describe("ShellParse", () => {
test("splits bash commands and derives reusable prefixes", async () => {
const result = await Effect.runPromise(
ShellParse.scan("git status && npm run test -- --watch", "/bin/bash", "/workspace"),
)
expect(result).toEqual({
commands: [
{ resource: "git status", save: "git status *" },
{ resource: "npm run test -- --watch", save: "npm run test *" },
],
directories: [],
})
})
test("splits PowerShell commands case-insensitively", async () => {
const result = await Effect.runPromise(
ShellParse.scan("Get-ChildItem; Write-Output 'done'", "C:\\Program Files\\PowerShell\\7\\pwsh.exe", "C:\\workspace"),
)
expect(result.commands).toEqual([
{ resource: "Get-ChildItem", save: "Get-ChildItem *" },
{ resource: "Write-Output 'done'", save: "Write-Output *" },
])
})
test("does not permission directory changes separately", async () => {
const result = await Effect.runPromise(ShellParse.scan("cd 'src dir' && git status", "/bin/bash", "/workspace"))
expect(result).toEqual({
commands: [{ resource: "git status", save: "git status *" }],
directories: ["src dir"],
})
})
test("extracts PowerShell directory parameters", async () => {
const result = await Effect.runPromise(
ShellParse.scan("Set-Location -LiteralPath '..\\outside'; Get-ChildItem", "pwsh", "C:\\workspace"),
)
expect(result.directories).toEqual(["..\\outside"])
})
test("expands deterministic directory variables", async () => {
const bash = await Effect.runPromise(ShellParse.scan("cd ~/src", "/bin/bash", "/workspace"))
expect(bash.directories).toEqual([path.join(os.homedir(), "src")])
const powershell = await Effect.runPromise(
ShellParse.scan('Set-Location "$PWD/src"; Set-Location $PSHOME', "/usr/local/bin/pwsh", "/workspace"),
)
expect(powershell.directories).toEqual(["/workspace/src", "/usr/local/bin"])
})
})
+2 -74
View File
@@ -1,6 +1,5 @@
import fs from "fs/promises"
import { realpathSync } from "node:fs"
import os from "os"
import path from "path"
import { describe, expect } from "bun:test"
import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
@@ -223,10 +222,7 @@ describe("ShellTool", () => {
type: "text",
text: expect.stringContaining("Command exited with code 0."),
})
expect(assertions).toMatchObject([
{ sessionID, action: "shell", resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand] },
])
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
expect(assertions).toMatchObject([{ sessionID, action: "shell", resources: [helloCommand] }])
}),
)
},
@@ -257,29 +253,6 @@ describe("ShellTool", () => {
),
)
it.live("permissions compound commands separately", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions).toHaveLength(1)
expect(assertions[0]).toMatchObject({
resources: ["printf one", "printf two"],
save: ["printf *", "printf *"],
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("captures stderr-only and mixed stdout/stderr output", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -352,51 +325,6 @@ describe("ShellTool", () => {
),
)
it.live("approves an external directory used by a directory-change command", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
const command = isWindows
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
: `cd '${outside.path}' && pwd`
return withSession(active.path, (registry) => executeTool(registry, call({ command }, "call-external-cd"))).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("approves an expanded external home directory", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const command = isWindows ? "Set-Location $HOME; (Get-Location).Path" : "cd ~ && pwd"
return withSession(tmp.path, (registry) => executeTool(registry, call({ command }, "call-external-home"))).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]?.resources[0]).toStartWith(os.homedir().replaceAll("\\", "/"))
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("does not execute after external-directory or shell denial", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
@@ -538,7 +466,7 @@ describe("ShellTool", () => {
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 500 : 50 })),
executeTool(registry, call({ command: timeoutOutputCommand, timeout: 50 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
+1 -1
View File
@@ -47,7 +47,7 @@ const executionNode = makeGlobalNode({
const completed = new Set<Session.ID>()
const complete = Effect.fn("SubagentTest.complete")(function* (sessionID: Session.ID) {
if (completed.has(sessionID)) return
if ((yield* store.get(sessionID))?.title?.includes("fail")) {
if ((yield* store.get(sessionID))?.title.includes("fail")) {
yield* new SessionRunnerModel.ModelNotSelectedError({ sessionID })
return
}
@@ -158,11 +158,6 @@ export default function () {
const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
const info = createMemo(() => data().session[match().index])
const title = createMemo(
() =>
info().title ??
`${info().parentID ? "Child" : "New"} session - ${new Date(info().time.created).toISOString()}`,
)
const ogImage = createMemo(() => {
const models = new Set<string>()
const messages = data().message[data().sessionID] ?? []
@@ -172,7 +167,7 @@ export default function () {
}
}
const modelIDs = Array.from(models)
const encodedTitle = encodeURIComponent(Base64.encode(encodeURIComponent(title().substring(0, 700))))
const encodedTitle = encodeURIComponent(Base64.encode(encodeURIComponent(info().title.substring(0, 700))))
let modelParam: string
if (modelIDs.length === 1) {
modelParam = modelIDs[0]
@@ -189,7 +184,9 @@ export default function () {
return (
<>
<Title>{title()} | OpenCode</Title>
<Show when={info().title}>
<Title>{info().title} | OpenCode</Title>
</Show>
<Meta name="description" content="opencode - The AI coding agent built for the terminal." />
<Meta property="og:image" content={ogImage()} />
<Meta name="twitter:image" content={ogImage()} />
@@ -243,7 +240,7 @@ export default function () {
</div>
</div>
</div>
<div class="text-left text-16-medium text-text-strong">{title()}</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
)
-8
View File
@@ -1,6 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
@@ -16,15 +15,8 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionRequest extends HttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
}
export interface SessionHooks {
readonly context: SessionContext
readonly request: SessionRequest
}
export type SessionDomain = Pick<
-8
View File
@@ -1,6 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
@@ -16,15 +15,8 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionRequest extends HttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
}
export interface SessionHooks {
readonly context: SessionContext
readonly request: SessionRequest
}
export type SessionDomain = Pick<
+1 -1
View File
@@ -42,7 +42,7 @@ export const Info = Schema.Struct({
updated: DateTimeUtcFromMillis,
archived: DateTimeUtcFromMillis.pipe(optional),
}),
title: Schema.String.pipe(optional),
title: Schema.String,
location: Location.Ref,
subpath: RelativePath.pipe(optional),
revert: Revert.pipe(optional),
+1 -1
View File
@@ -552,7 +552,7 @@ export const SessionInfo = Schema.Struct({
cost: optional(Schema.Finite),
tokens: optional(SessionTokens),
share: optional(SessionShare),
title: optional(Schema.String),
title: Schema.String,
agent: optional(Schema.String),
model: optional(SessionModel),
version: Schema.String,
+1 -13
View File
@@ -17,7 +17,7 @@ import { Money } from "../src/money.js"
import { Skill } from "../src/skill.js"
import { Shell } from "../src/shell.js"
import { PersistedRevert } from "../src/session-revert.js"
import { AbsolutePath, optional } from "../src/schema.js"
import { optional } from "../src/schema.js"
describe("contract hygiene", () => {
test("restricts agent colors to six-digit hex values", () => {
@@ -52,18 +52,6 @@ describe("contract hygiene", () => {
metadata: undefined,
}),
).toEqual({ text: "completed" })
expect(
Schema.encodeSync(Session.Info)({
id: Session.ID.make("ses_untitled"),
projectID: Project.ID.make("global"),
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
title: undefined,
location: { directory: AbsolutePath.make("/project") },
}),
).not.toHaveProperty("title")
})
test("pending session items omit the internal admission sequence", () => {
@@ -3,7 +3,6 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
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"
@@ -141,46 +140,44 @@ export function PromptInputV2(props: PromptInputV2Props) {
</Show>
<div class="relative min-h-[60px]">
<ScrollView class="min-h-[60px] max-h-[180px]">
<div
ref={(element) => {
editor = element
props.controller.setEditor(element)
renderPromptInputV2Editor(element, props.controller.parts())
}}
data-component="prompt-input"
role="textbox"
aria-multiline="true"
aria-label="Prompt"
contenteditable={!props.disabled && !props.readOnly}
autocapitalize={state.mode === "normal" ? "sentences" : "off"}
autocorrect={state.mode === "normal" ? "on" : "off"}
spellcheck={state.mode === "normal"}
// @ts-expect-error
autocomplete="off"
class="relative z-10 block min-h-[60px] w-full whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
onInput={(event) => {
const cursor = promptInputV2Cursor(event.currentTarget)
const prompt = parsePromptInputV2Editor(event.currentTarget)
const images = props.controller.parts().filter((part) => part.type === "image")
localInput = true
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
}}
onKeyDown={(event) => {
if (props.controller.onKeyDown(event)) return
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault()
if (event.repeat) return
props.controller.submit()
}
}}
onKeyUp={updateCursor}
onPointerUp={updateCursor}
onPaste={props.controller.onPaste}
onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
/>
</ScrollView>
<div
ref={(element) => {
editor = element
props.controller.setEditor(element)
renderPromptInputV2Editor(element, props.controller.parts())
}}
data-component="prompt-input"
role="textbox"
aria-multiline="true"
aria-label="Prompt"
contenteditable={!props.disabled && !props.readOnly}
autocapitalize={state.mode === "normal" ? "sentences" : "off"}
autocorrect={state.mode === "normal" ? "on" : "off"}
spellcheck={state.mode === "normal"}
// @ts-expect-error
autocomplete="off"
class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
onInput={(event) => {
const cursor = promptInputV2Cursor(event.currentTarget)
const prompt = parsePromptInputV2Editor(event.currentTarget)
const images = props.controller.parts().filter((part) => part.type === "image")
localInput = true
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
}}
onKeyDown={(event) => {
if (props.controller.onKeyDown(event)) return
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault()
if (event.repeat) return
props.controller.submit()
}
}}
onKeyUp={updateCursor}
onPointerUp={updateCursor}
onPaste={props.controller.onPaste}
onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
/>
<Show when={!props.controller.value()}>
<div
class="pointer-events-none absolute inset-x-0 top-0 px-4 pt-4 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
@@ -41,7 +41,6 @@ export const create = Effect.fn("SimulationRenderer.create")(function* (
...options,
width: cols,
height: rows,
kittyKeyboard: Boolean(options.useKittyKeyboard),
...(recording
? {
stdout: recording as unknown as NodeJS.WriteStream,

Some files were not shown because too many files have changed in this diff Show More