mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-05 16:41:01 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6639cb5ad | |||
| b300116d0a |
@@ -52,6 +52,7 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
readonly all: () => Effect.Effect<Model.Info[]>
|
||||
readonly available: () => Effect.Effect<Model.Info[]>
|
||||
readonly default: () => Effect.Effect<Model.Info | undefined>
|
||||
readonly small: (providerID: Provider.ID) => Effect.Effect<Model.Info | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +206,59 @@ const layer = Layer.effect(
|
||||
|
||||
return (yield* result.model.available())[0]
|
||||
}),
|
||||
|
||||
small: Effect.fn("Catalog.model.small")(function* (providerID) {
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return
|
||||
const provider = record.provider
|
||||
|
||||
// TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments.
|
||||
if (providerID === Provider.ID.azure || providerID === Provider.ID.make("azure-cognitive-services")) {
|
||||
return
|
||||
}
|
||||
|
||||
if (providerID === Provider.ID.opencode) {
|
||||
const gpt5Nano = record.models.get(Model.ID.make("gpt-5-nano"))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
|
||||
}
|
||||
|
||||
const candidates = pipe(
|
||||
Array.fromIterable(record.models.values()),
|
||||
Array.filter(
|
||||
(model) =>
|
||||
model.providerID === providerID &&
|
||||
model.enabled &&
|
||||
model.status === "active" &&
|
||||
model.capabilities.input.some((item) => item.startsWith("text")) &&
|
||||
model.capabilities.output.some((item) => item.startsWith("text")),
|
||||
),
|
||||
Array.map((model) => ({
|
||||
model,
|
||||
cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
|
||||
age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30),
|
||||
small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
|
||||
})),
|
||||
Array.filter((item) => item.cost > 0 && item.age <= 18),
|
||||
)
|
||||
|
||||
const pick = (items: typeof candidates) => {
|
||||
if (!Array.isReadonlyArrayNonEmpty(items)) return
|
||||
const maxCost = Math.max(...items.map((item) => item.cost), 0.01)
|
||||
const maxAge = Math.max(...items.map((item) => item.age), 0.01)
|
||||
const selected = Array.min(
|
||||
items,
|
||||
Order.mapInput(
|
||||
Order.Number,
|
||||
(item: (typeof candidates)[number]) =>
|
||||
(item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2,
|
||||
),
|
||||
)
|
||||
return projectModel(selected.model, provider)
|
||||
}
|
||||
|
||||
const small = candidates.filter((item) => item.small)
|
||||
return pick(small.length > 0 ? small : candidates)
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -212,4 +266,6 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Integration.node] })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
@@ -291,4 +292,46 @@ describe("Catalog", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("small model prefers small keyword candidates before cost scoring", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("test")
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, Model.ID.make("cheap-large"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(1),
|
||||
output: Money.USDPerMillionTokens.make(1),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.zero,
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
]
|
||||
model.time.released = Date.now()
|
||||
})
|
||||
catalog.model.update(providerID, Model.ID.make("expensive-mini"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(10),
|
||||
output: Money.USDPerMillionTokens.make(10),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.zero,
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
]
|
||||
model.time.released = Date.now()
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ const catalog = Layer.mock(Catalog.Service, {
|
||||
all: () => Effect.die("unused"),
|
||||
available: () => Effect.die("unused"),
|
||||
default: () => Effect.die("unused"),
|
||||
small: () => Effect.die("unused"),
|
||||
},
|
||||
})
|
||||
const integrations = Layer.mock(Integration.Service, {
|
||||
|
||||
@@ -484,4 +484,31 @@ describe("OpencodePlugin", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("prefers gpt-5-nano as the opencode small model", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.opencode
|
||||
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, Model.ID.make("cheap-mini"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = [...cost(1, 1)]
|
||||
model.time.released = Date.now()
|
||||
})
|
||||
catalog.model.update(providerID, Model.ID.make("gpt-5-nano"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = [...cost(10, 10)]
|
||||
model.time.released = Date.now()
|
||||
})
|
||||
})
|
||||
|
||||
const selected = yield* catalog.model.small(providerID)
|
||||
|
||||
expect(selected?.id).toBe(Model.ID.make("gpt-5-nano"))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -102,6 +102,7 @@ const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
all: () => Effect.succeed([]),
|
||||
available: () => Effect.succeed([]),
|
||||
default: () => Effect.succeed(undefined),
|
||||
small: () => Effect.succeed(undefined),
|
||||
},
|
||||
})
|
||||
const runnerLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
|
||||
@@ -362,6 +362,7 @@ const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
all: () => Effect.succeed([]),
|
||||
available: () => Effect.succeed([]),
|
||||
default: () => Effect.succeed(undefined),
|
||||
small: () => Effect.succeed(undefined),
|
||||
},
|
||||
})
|
||||
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
|
||||
@@ -96,6 +96,7 @@ import { findMessageBoundary, messageNavigationSlack } from "./message-navigatio
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { installSyntaxHighlightCache } from "../../util/syntax-highlight-cache"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -128,6 +129,7 @@ function use() {
|
||||
}
|
||||
|
||||
export function Session() {
|
||||
installSyntaxHighlightCache()
|
||||
const setEpilogue = useEpilogue()
|
||||
const clipboard = useClipboard()
|
||||
const writeExport = async (file: string, content: string) => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getTreeSitterClient, type TreeSitterClient } from "@opentui/core"
|
||||
|
||||
const CACHE_SIZE = 500
|
||||
const installed = new WeakSet<TreeSitterClient>()
|
||||
|
||||
export function installSyntaxHighlightCache() {
|
||||
const client = getTreeSitterClient()
|
||||
if (installed.has(client)) return
|
||||
installed.add(client)
|
||||
client.highlightOnce = cacheHighlights(client.highlightOnce.bind(client))
|
||||
}
|
||||
|
||||
export function cacheHighlights(highlight: TreeSitterClient["highlightOnce"], capacity = CACHE_SIZE) {
|
||||
const cache = new Map<string, ReturnType<TreeSitterClient["highlightOnce"]>>()
|
||||
|
||||
return (content: string, filetype: string) => {
|
||||
const key = `${filetype}\0${content}`
|
||||
const cached = cache.get(key)
|
||||
if (cached) {
|
||||
cache.delete(key)
|
||||
cache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const result = highlight(content, filetype)
|
||||
cache.set(key, result)
|
||||
if (cache.size > capacity) cache.delete(cache.keys().next().value!)
|
||||
|
||||
void result
|
||||
.then((value) => {
|
||||
if (value.error && cache.get(key) === result) cache.delete(key)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cache.get(key) === result) cache.delete(key)
|
||||
})
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { cacheHighlights } from "../../src/util/syntax-highlight-cache"
|
||||
|
||||
describe("syntax highlight cache", () => {
|
||||
test("reuses completed and in-flight highlights", async () => {
|
||||
let calls = 0
|
||||
const highlight = cacheHighlights(async () => {
|
||||
calls++
|
||||
return { highlights: [[0, 5, "keyword"]] }
|
||||
})
|
||||
|
||||
const first = highlight("const", "typescript")
|
||||
const second = highlight("const", "typescript")
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(await second).toEqual({ highlights: [[0, 5, "keyword"]] })
|
||||
expect(await highlight("const", "typescript")).toEqual({ highlights: [[0, 5, "keyword"]] })
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test("evicts least recently used highlights", async () => {
|
||||
let calls = 0
|
||||
const highlight = cacheHighlights(async () => {
|
||||
calls++
|
||||
return { highlights: [] }
|
||||
}, 2)
|
||||
|
||||
await highlight("one", "text")
|
||||
await highlight("two", "text")
|
||||
await highlight("one", "text")
|
||||
await highlight("three", "text")
|
||||
await highlight("two", "text")
|
||||
|
||||
expect(calls).toBe(4)
|
||||
})
|
||||
|
||||
test("retries failed highlights", async () => {
|
||||
let calls = 0
|
||||
const highlight = cacheHighlights(async () => {
|
||||
calls++
|
||||
if (calls === 1) return { error: "parser unavailable" }
|
||||
return { highlights: [] }
|
||||
})
|
||||
|
||||
await highlight("const", "typescript")
|
||||
await highlight("const", "typescript")
|
||||
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
test("an evicted failure does not delete its replacement", async () => {
|
||||
const pending = Promise.withResolvers<{ highlights: [] }>()
|
||||
let calls = 0
|
||||
const highlight = cacheHighlights(() => {
|
||||
calls++
|
||||
if (calls === 1) return pending.promise
|
||||
return Promise.resolve({ highlights: [] })
|
||||
}, 1)
|
||||
|
||||
const stale = highlight("one", "text")
|
||||
await highlight("two", "text")
|
||||
const current = highlight("one", "text")
|
||||
pending.reject(new Error("parser unavailable"))
|
||||
|
||||
await expect(stale).rejects.toThrow("parser unavailable")
|
||||
expect(highlight("one", "text")).toBe(current)
|
||||
expect(calls).toBe(3)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user