mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-16 05:46:23 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f3a08a64f | ||
|
|
9bf5faa978 | ||
|
|
a062f4e803 |
@@ -59,7 +59,15 @@ export const isContextOverflowFailure = (failure: unknown) =>
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
|
||||
const QUOTA_CODES = new Set([
|
||||
"insufficient_quota",
|
||||
"usage_not_included",
|
||||
"billing_error",
|
||||
"gousagelimiterror",
|
||||
"freeusagelimiterror",
|
||||
"creditlimitexceeded",
|
||||
])
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
@@ -87,7 +95,8 @@ const CONTENT_POLICY_CODES = new Set([
|
||||
// as a `[code]` label at the start of the rewritten message.
|
||||
const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
// Only consulted on 429, where throttles and account caps share a status.
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
|
||||
// Policy rejections without a dedicated code, matched against the provider's own
|
||||
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
|
||||
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
|
||||
@@ -143,7 +152,11 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
|
||||
if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || (clientScoped && CONTENT_POLICY_TEXT.test(input.message)))
|
||||
return new ContentPolicyError(details)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
if (
|
||||
input.status === 402 ||
|
||||
codes.some((code) => QUOTA_CODES.has(code)) ||
|
||||
(input.status === 429 && QUOTA_TEXT.test(text))
|
||||
)
|
||||
return new QuotaExceededError(details)
|
||||
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
|
||||
return new AuthenticationError(details)
|
||||
@@ -163,10 +176,12 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
input.status === 408 ||
|
||||
input.status === 409 ||
|
||||
(input.status !== undefined && input.status >= 500) ||
|
||||
// Server codes and phrasing only decide when no HTTP status contradicts them:
|
||||
// gateways such as OpenCode Zen substitute `server_error` for codes they do
|
||||
// not forward, so a 4xx with a server code is still a rejected request.
|
||||
((input.status === undefined || input.status < 400) &&
|
||||
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
|
||||
SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
|
||||
((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))))
|
||||
)
|
||||
return new ProviderInternalError({
|
||||
...details,
|
||||
|
||||
@@ -309,7 +309,7 @@ describe("RequestExecutor", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies provider overloads hidden behind HTTP 400", () =>
|
||||
it.effect("does not let server codes override a 4xx rejection", () =>
|
||||
Effect.gen(function* () {
|
||||
const classify = (body: string) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -317,11 +317,11 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
|
||||
|
||||
yield* classify('{"code":"resource_exhausted"}')
|
||||
yield* classify('{"code":"service_unavailable"}')
|
||||
yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}')
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -249,10 +249,54 @@ describe("provider error classification", () => {
|
||||
|
||||
test("classifies any remaining 4xx status as an invalid request", () => {
|
||||
expect(
|
||||
[400, 402, 404, 418, 422, 451].map(
|
||||
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
|
||||
[400, 404, 418, 422, 451].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
|
||||
).toEqual(Array(5).fill("InvalidRequest"))
|
||||
})
|
||||
|
||||
test("classifies 402 as exhausted quota", () => {
|
||||
expect(classifyProviderFailure({ message: "Payment Required", status: 402 })._tag).toBe("QuotaExceeded")
|
||||
})
|
||||
|
||||
test("classifies OpenCode Zen account limits as quota rather than throttling", () => {
|
||||
const typed = (type: string, message: string) => ({ type: "error", error: { type, message } })
|
||||
const substituted = (message: string) => ({
|
||||
error: { type: "server_error", message: `Upstream request failed: ${message}` },
|
||||
})
|
||||
const cases: ReadonlyArray<[number, { error: { message: string } }]> = [
|
||||
[429, typed("GoUsageLimitError", "Go usage limit exceeded")],
|
||||
[429, typed("FreeUsageLimitError", "Rate limit exceeded. Please try again later.")],
|
||||
[402, typed("CreditLimitExceeded", "Credit limit exceeded.")],
|
||||
[402, substituted("Insufficient account funds")],
|
||||
[402, substituted("Account invoice is overdue")],
|
||||
[429, substituted("Account budget exceeded")],
|
||||
]
|
||||
expect(
|
||||
cases.map(
|
||||
([status, body]) =>
|
||||
classifyProviderFailure({ message: body.error.message, status, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(Array(6).fill("InvalidRequest"))
|
||||
).toEqual(Array(6).fill("QuotaExceeded"))
|
||||
})
|
||||
|
||||
test("does not let substituted server codes make a 4xx retryable", () => {
|
||||
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
|
||||
const anthropic = {
|
||||
type: "error",
|
||||
error: { type: "api_error", message: "Upstream request failed: Model is unavailable." },
|
||||
}
|
||||
expect(
|
||||
[openai, anthropic].map(
|
||||
(body) =>
|
||||
classifyProviderFailure({ message: body.error.message, status: 400, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(["InvalidRequest", "InvalidRequest"])
|
||||
// Without a contradicting status the same codes still mark provider trouble.
|
||||
expect(classifyProviderFailure({ message: openai.error.message, rawBody: JSON.stringify(openai) })._tag).toBe(
|
||||
"ProviderInternal",
|
||||
)
|
||||
expect(
|
||||
classifyProviderFailure({ message: openai.error.message, status: 200, rawBody: JSON.stringify(openai) })._tag,
|
||||
).toBe("ProviderInternal")
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
|
||||
+76
-57
@@ -68,7 +68,7 @@ export interface Interface extends State.Transformable<Editor> {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Model") {}
|
||||
|
||||
type Data = {
|
||||
models: Map<Provider.ID, Map<ID, MutableInfo>>
|
||||
models: Map<Provider.ID, ReadonlyMap<ID, Info>>
|
||||
defaultModel?: { providerID: Provider.ID; modelID: ID }
|
||||
}
|
||||
|
||||
@@ -82,59 +82,73 @@ const layer = Layer.effect(
|
||||
const state: State.Interface<Data, Editor> = State.create<Data, Editor>({
|
||||
name: "model",
|
||||
initial: () => ({
|
||||
models: new Map(
|
||||
(input?.available ?? []).map((record) => [
|
||||
record.provider.id,
|
||||
new Map(
|
||||
Array.from(record.models, ([id, model]) => [
|
||||
id,
|
||||
{
|
||||
...structuredClone(model),
|
||||
id,
|
||||
providerID: record.provider.id,
|
||||
} as MutableInfo,
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
models: new Map((input?.available ?? []).map((record) => [record.provider.id, record.models])),
|
||||
}),
|
||||
editor: (data) => ({
|
||||
list: (providerID) =>
|
||||
providerID === undefined
|
||||
? Array.from(data.models.values()).flatMap((models) => Array.from(models.values()))
|
||||
: Array.from(data.models.get(providerID)?.values() ?? []),
|
||||
get: (providerID, modelID) => data.models.get(providerID)?.get(modelID),
|
||||
update: (providerID, modelID, update) => {
|
||||
// Model edits cannot create/enable a provider or bypass its availability decision.
|
||||
const models = data.models.get(providerID)
|
||||
if (!models) return
|
||||
const model = models.get(modelID) ?? (Info.default(providerID, modelID) as MutableInfo)
|
||||
update(model)
|
||||
model.id = modelID
|
||||
model.providerID = providerID
|
||||
const provider = input?.records.get(providerID)?.provider
|
||||
AISDKNative.rewrite(model, {
|
||||
specifier: model.package ?? provider?.package,
|
||||
providerID,
|
||||
canonical: model.canonical ?? provider?.canonical,
|
||||
modelID: model.modelID ?? modelID,
|
||||
})
|
||||
models.set(modelID, model)
|
||||
},
|
||||
remove: (providerID, modelID) => {
|
||||
data.models.get(providerID)?.delete(modelID)
|
||||
},
|
||||
default: {
|
||||
get: () => data.defaultModel,
|
||||
set: (providerID, modelID) => {
|
||||
data.defaultModel = { providerID, modelID }
|
||||
editor: (data) => {
|
||||
// Definitions are shared across Locations; a provider's map and a model are copied before their first edit.
|
||||
const owned = new WeakSet<ReadonlyMap<ID, Info>>()
|
||||
const drafts = new WeakSet<Info>()
|
||||
const writable = (providerID: Provider.ID) => {
|
||||
const current = data.models.get(providerID)
|
||||
if (!current) return undefined
|
||||
if (owned.has(current)) return current as Map<ID, Info>
|
||||
const copy = new Map(current)
|
||||
owned.add(copy)
|
||||
data.models.set(providerID, copy)
|
||||
return copy
|
||||
}
|
||||
const draft = (providerID: Provider.ID, modelID: ID) => {
|
||||
const models = writable(providerID)
|
||||
if (!models) return undefined
|
||||
const current = models.get(modelID)
|
||||
if (!current) return undefined
|
||||
if (drafts.has(current)) return current as MutableInfo
|
||||
const copy = structuredClone(current) as MutableInfo
|
||||
drafts.add(copy)
|
||||
models.set(modelID, copy)
|
||||
return copy
|
||||
}
|
||||
return {
|
||||
list: (providerID) => {
|
||||
const ids = providerID === undefined ? Array.from(data.models.keys()) : [providerID]
|
||||
return ids.flatMap((id) =>
|
||||
Array.from(data.models.get(id)?.keys() ?? []).flatMap((modelID) => draft(id, modelID) ?? []),
|
||||
)
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
list: () => Array.from(input?.records.values() ?? []),
|
||||
get: (providerID) => input?.records.get(providerID),
|
||||
},
|
||||
}),
|
||||
get: draft,
|
||||
update: (providerID, modelID, update) => {
|
||||
// Model edits cannot create/enable a provider or bypass its availability decision.
|
||||
const models = writable(providerID)
|
||||
if (!models) return
|
||||
const model = draft(providerID, modelID) ?? (Info.default(providerID, modelID) as MutableInfo)
|
||||
update(model)
|
||||
model.id = modelID
|
||||
model.providerID = providerID
|
||||
const provider = input?.records.get(providerID)?.provider
|
||||
AISDKNative.rewrite(model, {
|
||||
specifier: model.package ?? provider?.package,
|
||||
providerID,
|
||||
canonical: model.canonical ?? provider?.canonical,
|
||||
modelID: model.modelID ?? modelID,
|
||||
})
|
||||
drafts.add(model)
|
||||
models.set(modelID, model)
|
||||
},
|
||||
remove: (providerID, modelID) => {
|
||||
writable(providerID)?.delete(modelID)
|
||||
},
|
||||
default: {
|
||||
get: () => data.defaultModel,
|
||||
set: (providerID, modelID) => {
|
||||
data.defaultModel = { providerID, modelID }
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
list: () => Array.from(input?.records.values() ?? []),
|
||||
get: (providerID) => input?.records.get(providerID),
|
||||
},
|
||||
}
|
||||
},
|
||||
// read() also refreshes dependencies changed inside a State.batch before notification.
|
||||
notify: () => notify,
|
||||
})
|
||||
@@ -147,6 +161,8 @@ const layer = Layer.effect(
|
||||
byProvider: ReadonlyMap<Provider.ID, ReadonlyMap<ID, Info>>
|
||||
}
|
||||
| undefined
|
||||
// An unedited model keeps its shared definition object across rebuilds, so its merged output is reusable.
|
||||
const merged = new WeakMap<Info, { provider: Provider.Info | undefined; model: Info }>()
|
||||
const read = Effect.fn("Model.snapshot")(function* () {
|
||||
while (true) {
|
||||
const current = yield* providers.snapshot()
|
||||
@@ -165,9 +181,10 @@ const layer = Layer.effect(
|
||||
return [
|
||||
providerID,
|
||||
new Map(
|
||||
Array.from(models, ([id, model]) => [
|
||||
id,
|
||||
{
|
||||
Array.from(models, ([id, model]) => {
|
||||
const reusable = merged.get(model)
|
||||
if (reusable && reusable.provider === provider) return [id, reusable.model]
|
||||
const value = {
|
||||
...model,
|
||||
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
|
||||
package: model.package ?? provider?.package,
|
||||
@@ -176,8 +193,10 @@ const layer = Layer.effect(
|
||||
settings: Provider.mergeOverlay(provider?.settings, model.settings),
|
||||
headers: Provider.mergeHeaders(provider?.headers, model.headers),
|
||||
body: Provider.mergeOverlay(provider?.body, model.body),
|
||||
} satisfies Info,
|
||||
]),
|
||||
} satisfies Info
|
||||
merged.set(model, { provider, model: value })
|
||||
return [id, value]
|
||||
}),
|
||||
),
|
||||
]
|
||||
}),
|
||||
|
||||
@@ -228,12 +228,18 @@ export interface Interface extends State.Transformable<Editor> {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Provider") {}
|
||||
|
||||
// Every location references the same index for a shared immutable definition array.
|
||||
const definitions = new WeakMap<readonly Model.Info[], ReadonlyMap<Model.ID, Model.Info>>()
|
||||
function index(models: readonly Model.Info[]) {
|
||||
const cached = definitions.get(models)
|
||||
const definitions = new WeakMap<readonly Model.Info[], Map<ID, ReadonlyMap<Model.ID, Model.Info>>>()
|
||||
function index(providerID: ID, models: readonly Model.Info[]) {
|
||||
const indexes = definitions.get(models) ?? new Map<ID, ReadonlyMap<Model.ID, Model.Info>>()
|
||||
const cached = indexes.get(providerID)
|
||||
if (cached) return cached
|
||||
const result = freeze(new Map(models.map((model) => [model.id, model])), true)
|
||||
definitions.set(models, result)
|
||||
// Model shares these definitions without copying, so a foreign definition takes this provider's identity here.
|
||||
const result = freeze(
|
||||
new Map(models.map((model) => [model.id, model.providerID === providerID ? model : { ...model, providerID }])),
|
||||
true,
|
||||
)
|
||||
indexes.set(providerID, result)
|
||||
definitions.set(models, indexes)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -281,7 +287,7 @@ const layer = Layer.effect(
|
||||
add: (definition) => {
|
||||
records.set(definition.info.id, {
|
||||
provider: structuredClone(definition.info) as MutableInfo,
|
||||
models: index(definition.models),
|
||||
models: index(definition.info.id, definition.models),
|
||||
sourceConnection: definition.sourceConnection,
|
||||
})
|
||||
},
|
||||
@@ -300,7 +306,7 @@ const layer = Layer.effect(
|
||||
},
|
||||
models: {
|
||||
set: (id, values) => {
|
||||
entry(id).models = index(values)
|
||||
entry(id).models = index(id, values)
|
||||
},
|
||||
update: (providerID, modelID, update) => {
|
||||
const record = entry(providerID)
|
||||
@@ -338,7 +344,7 @@ const layer = Layer.effect(
|
||||
// Registrations may outlive a borrowed service layer; their later disposal must
|
||||
// not query dependencies that have already closed.
|
||||
yield* Effect.addFinalizer(() => State.shutdown(state.reload()))
|
||||
let cached: { records: Snapshot["records"]; access: string; value: Snapshot } | undefined
|
||||
let cached: { records: Snapshot["records"]; value: Snapshot } | undefined
|
||||
const snapshot = Effect.fn("Provider.snapshot")(function* () {
|
||||
while (true) {
|
||||
const revision = integrations.revision()
|
||||
@@ -347,10 +353,6 @@ const layer = Layer.effect(
|
||||
const connections = yield* integrations.list()
|
||||
// Either fold can disable a plugin that also contributed to the other domain.
|
||||
if (revision !== integrations.revision() || records !== state.get()) continue
|
||||
const access = JSON.stringify(
|
||||
connections.map((integration) => [integration.id, integration.connections.map(IntegrationConnection.key)]),
|
||||
)
|
||||
if (cached?.records === records && cached.access === access) return cached.value
|
||||
const byID = new Map(connections.map((integration) => [integration.id, integration]))
|
||||
const available = Array.from(records.values()).filter((record) => {
|
||||
if (record.provider.activation === "disabled") return false
|
||||
@@ -366,8 +368,15 @@ const layer = Layer.effect(
|
||||
if (integration?.connections.length) return true
|
||||
return record.provider.integrationID === undefined && !integration
|
||||
})
|
||||
// A credential change that leaves the same definitions available is not a catalog change.
|
||||
if (
|
||||
cached?.records === records &&
|
||||
cached.value.available.length === available.length &&
|
||||
cached.value.available.every((record, index) => record === available[index])
|
||||
)
|
||||
return cached.value
|
||||
const value = freeze({ records, available, providers: available.map((record) => record.provider) }, true)
|
||||
cached = { records, access, value }
|
||||
cached = { records, value }
|
||||
return value
|
||||
}
|
||||
})
|
||||
|
||||
@@ -804,12 +804,12 @@ it.effect("classifies retryable AI SDK failures with retry-after details", () =>
|
||||
it.effect("classifies data-only AI SDK provider codes", () =>
|
||||
Effect.gen(function* () {
|
||||
const data = {
|
||||
error: { code: "api_error", metadata: { requestId: "data-request", retryable: true } },
|
||||
error: { code: "rate_limit_error", metadata: { requestId: "data-request", retryable: true } },
|
||||
trace: { region: "test-region" },
|
||||
}
|
||||
const cause = apiCallError({ statusCode: 400, data })
|
||||
const error = yield* streamFailure(cause)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
|
||||
expect(error.reason.http?.status).toBe(400)
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
||||
expect(error.reason.body).toBe(JSON.stringify(data))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { LanguageModel } from "@opencode/ai"
|
||||
import { OpenAIChat } from "@opencode/ai/protocols"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Effect, Fiber, Layer, Ref, Stream } from "effect"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { Credential } from "@opencode/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
@@ -148,6 +148,156 @@ describe("Provider and Model", () => {
|
||||
}).pipe(Effect.scoped, Effect.provide(localProviderLayer))
|
||||
})
|
||||
|
||||
it.effect("reuses the model catalog across credential switches", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const bus = yield* Bus.Service
|
||||
const providerID = Provider.ID.make("switchable")
|
||||
const integrationID = Integration.ID.make(providerID)
|
||||
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
|
||||
yield* providers.transform((editor) =>
|
||||
editor.add({
|
||||
info: Provider.Info.empty(providerID),
|
||||
models: [Model.Info.default(providerID, Model.ID.make("chat"))],
|
||||
}),
|
||||
)
|
||||
expect(yield* models.available()).toEqual([])
|
||||
const log = yield* Ref.make<string[]>([])
|
||||
yield* bus.subscribe().pipe(
|
||||
Stream.runForEach((event) => Ref.update(log, (types) => [...types, event.type])),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
const updates = Ref.get(log).pipe(
|
||||
Effect.map((types) => types.filter((type) => type === Model.Event.Updated.type).length),
|
||||
)
|
||||
|
||||
const first = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "first" }),
|
||||
})
|
||||
const materialized = yield* models.available()
|
||||
expect(materialized).toHaveLength(1)
|
||||
// Credential events reach Model on other fibers; let the connect land before switching.
|
||||
yield* settle(updates.pipe(Effect.map((count) => count >= 1)))
|
||||
|
||||
const second = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "second" }),
|
||||
})
|
||||
expect(yield* models.available()).toBe(materialized)
|
||||
yield* credentials.activate(first.id)
|
||||
expect(yield* models.available()).toBe(materialized)
|
||||
yield* credentials.remove(first.id)
|
||||
expect(yield* models.available()).toBe(materialized)
|
||||
|
||||
// Disconnecting is a real change whose model.updated follows every earlier one in the log,
|
||||
// so once it has arrived the total shows whether any switch above published as well.
|
||||
yield* credentials.remove(second.id)
|
||||
expect(yield* models.available()).toEqual([])
|
||||
yield* settle(
|
||||
Ref.get(log).pipe(
|
||||
Effect.map(
|
||||
(types) =>
|
||||
types.lastIndexOf(Model.Event.Updated.type) > types.lastIndexOf(Credential.Event.Updated.type),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(yield* updates).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists direct edits to models returned by list and get", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
const providerID = Provider.ID.make("direct")
|
||||
const listed = Model.ID.make("listed")
|
||||
const fetched = Model.ID.make("fetched")
|
||||
const definitions = [Model.Info.default(providerID, listed), Model.Info.default(providerID, fetched)]
|
||||
yield* providers.transform((editor) =>
|
||||
editor.add({ info: { ...Provider.Info.empty(providerID), activation: "enabled" }, models: definitions }),
|
||||
)
|
||||
yield* models.transform((editor) => {
|
||||
editor.list(providerID).forEach((model) => {
|
||||
model.limit.context = 4096
|
||||
})
|
||||
required(editor.get(providerID, fetched)).capabilities.input.push("pdf")
|
||||
})
|
||||
|
||||
expect(yield* models.get(providerID, listed)).toMatchObject({
|
||||
limit: { context: 4096 },
|
||||
capabilities: { input: ["text", "image"] },
|
||||
})
|
||||
expect(yield* models.get(providerID, fetched)).toMatchObject({
|
||||
limit: { context: 4096 },
|
||||
capabilities: { input: ["text", "image", "pdf"] },
|
||||
})
|
||||
expect(definitions.map((model) => model.limit.context)).toEqual([200_000, 200_000])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("gives foreign definitions the registering provider's identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
const source = Provider.ID.make("source")
|
||||
const mirror = Provider.ID.make("mirror")
|
||||
const modelID = Model.ID.make("chat")
|
||||
const definitions = [Model.Info.default(source, modelID)]
|
||||
yield* providers.transform((editor) => {
|
||||
editor.add({ info: { ...Provider.Info.empty(source), activation: "enabled" }, models: definitions })
|
||||
editor.add({ info: { ...Provider.Info.empty(mirror), activation: "enabled" }, models: definitions })
|
||||
})
|
||||
|
||||
expect((yield* models.available()).map((model) => model.providerID).toSorted()).toEqual([mirror, source])
|
||||
expect(yield* models.get(mirror, modelID)).toMatchObject({ id: modelID, providerID: mirror })
|
||||
expect((yield* providers.snapshot()).records.get(source)?.models.get(modelID)).toBe(definitions[0])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps materialized models when another provider becomes available", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const existing = Provider.ID.make("existing")
|
||||
const added = Provider.ID.make("added")
|
||||
const edited = Model.ID.make("edited")
|
||||
const untouched = Model.ID.make("untouched")
|
||||
yield* integrations.transform((editor) => editor.update(Integration.ID.make(added), () => {}))
|
||||
yield* providers.transform((editor) => {
|
||||
editor.add({
|
||||
info: { ...Provider.Info.empty(existing), activation: "enabled" },
|
||||
models: [Model.Info.default(existing, edited), Model.Info.default(existing, untouched)],
|
||||
})
|
||||
editor.add({ info: Provider.Info.empty(added), models: [Model.Info.default(added, Model.ID.make("chat"))] })
|
||||
})
|
||||
yield* models.transform((editor) =>
|
||||
editor.update(existing, edited, (model) => {
|
||||
model.limit.context = 1
|
||||
}),
|
||||
)
|
||||
const before = required(yield* models.get(existing, untouched))
|
||||
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make(added),
|
||||
value: Credential.Key.make({ type: "key", key: "secret" }),
|
||||
})
|
||||
expect((yield* models.available()).map((model) => model.providerID).toSorted()).toEqual([
|
||||
added,
|
||||
existing,
|
||||
existing,
|
||||
])
|
||||
expect(yield* models.get(existing, untouched)).toBe(before)
|
||||
expect(yield* models.get(existing, edited)).toMatchObject({ limit: { context: 1 } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("derives availability from a provider's integration", () => {
|
||||
const integrationID = Integration.ID.make("gateway")
|
||||
const providerID = Provider.ID.make("remote")
|
||||
@@ -511,3 +661,12 @@ describe("Provider and Model", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Bus subscribers run on their own fibers, so give them turns until the condition holds.
|
||||
const settle = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (yield* condition) return
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
return yield* Effect.die("Timed out waiting for catalog events")
|
||||
})
|
||||
|
||||
@@ -151,13 +151,13 @@ describeHg("Vcs mercurial", () => {
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "default", default: "default" } })
|
||||
|
||||
const updated = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "default", default: "default" } })
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, {
|
||||
file: path.join(directory, ".hg", "branch"),
|
||||
@@ -167,7 +167,7 @@ describeHg("Vcs mercurial", () => {
|
||||
_tag: "Some",
|
||||
value: { location: { directory }, data: { branch: "feature" } },
|
||||
})
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "default" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "feature", default: "default" } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("Vcs", () => {
|
||||
editor.default.set("custom")
|
||||
})
|
||||
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.base()).toBeNull()
|
||||
expect(yield* vcs.branches()).toEqual(["feature", "main"])
|
||||
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
|
||||
@@ -146,10 +146,10 @@ describe("Vcs", () => {
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const registration = yield* vcs.transform((editor) => editor.add(provider({ id: "git" })))
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "feature", default: "main" } })
|
||||
|
||||
yield* registration.dispose
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -188,7 +188,7 @@ describe("Vcs", () => {
|
||||
}),
|
||||
)
|
||||
expect(reads).toEqual(["final"])
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "final" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "final" } })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -456,7 +456,7 @@ describe("Vcs", () => {
|
||||
),
|
||||
)
|
||||
expect((yield* vcs.status())[0]?.file).toBe("config.txt")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "initial" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "initial" } })
|
||||
yield* Deferred.succeed(accepted, undefined)
|
||||
}),
|
||||
).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
@@ -466,7 +466,7 @@ describe("Vcs", () => {
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(configured)
|
||||
expect(Option.getOrUndefined(yield* Fiber.join(updates))?.data.branch).toBe("config")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "config" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "config" } })
|
||||
expect(reads).toEqual(["initial", "filesystem", "config"])
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined)))
|
||||
}),
|
||||
@@ -502,7 +502,7 @@ describe("Vcs", () => {
|
||||
})
|
||||
yield* bus.publish(Done, {})
|
||||
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "listener" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "listener" } })
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
|
||||
}).pipe(Effect.ensuring(unsubscribe))
|
||||
@@ -567,7 +567,7 @@ describe("Vcs", () => {
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
|
||||
|
||||
const updated = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
@@ -575,14 +575,14 @@ describe("Vcs", () => {
|
||||
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
expect(yield* Fiber.join(updated)).toMatchObject({
|
||||
_tag: "Some",
|
||||
value: { location: { directory }, data: { branch: "feature" } },
|
||||
})
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "feature", default: "main" } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user