Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5435a699b8 | ||
|
|
2a118b5214 | ||
|
|
0c430ada0b | ||
|
|
370690c1f8 |
@@ -0,0 +1,175 @@
|
|||||||
|
import { AppLayer } from "@/effect/app-runtime"
|
||||||
|
import { memoMap } from "@/effect/run-service"
|
||||||
|
import { Provider } from "@/provider/provider"
|
||||||
|
import { lazy } from "@/util/lazy"
|
||||||
|
import { Effect, Layer, Schema } from "effect"
|
||||||
|
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||||
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
import type { Handler } from "hono"
|
||||||
|
import { mapValues } from "remeda"
|
||||||
|
|
||||||
|
const ApiInfo = Schema.Struct({
|
||||||
|
id: Schema.String,
|
||||||
|
url: Schema.String,
|
||||||
|
npm: Schema.String,
|
||||||
|
}).annotate({ identifier: "ConfigProvidersModelApi" })
|
||||||
|
|
||||||
|
const Mode = Schema.Struct({
|
||||||
|
text: Schema.Boolean,
|
||||||
|
audio: Schema.Boolean,
|
||||||
|
image: Schema.Boolean,
|
||||||
|
video: Schema.Boolean,
|
||||||
|
pdf: Schema.Boolean,
|
||||||
|
}).annotate({ identifier: "ConfigProvidersModelMode" })
|
||||||
|
|
||||||
|
const Interleaved = Schema.Union([
|
||||||
|
Schema.Boolean,
|
||||||
|
Schema.Struct({
|
||||||
|
field: Schema.Literals(["reasoning_content", "reasoning_details"]),
|
||||||
|
}),
|
||||||
|
]).annotate({ identifier: "ConfigProvidersModelInterleaved" })
|
||||||
|
|
||||||
|
const Capabilities = Schema.Struct({
|
||||||
|
temperature: Schema.Boolean,
|
||||||
|
reasoning: Schema.Boolean,
|
||||||
|
attachment: Schema.Boolean,
|
||||||
|
toolcall: Schema.Boolean,
|
||||||
|
input: Mode,
|
||||||
|
output: Mode,
|
||||||
|
interleaved: Interleaved,
|
||||||
|
}).annotate({ identifier: "ConfigProvidersModelCapabilities" })
|
||||||
|
|
||||||
|
const Cache = Schema.Struct({
|
||||||
|
read: Schema.Number,
|
||||||
|
write: Schema.Number,
|
||||||
|
}).annotate({ identifier: "ConfigProvidersModelCache" })
|
||||||
|
|
||||||
|
const ExperimentalOver200K = Schema.Struct({
|
||||||
|
input: Schema.Number,
|
||||||
|
output: Schema.Number,
|
||||||
|
cache: Cache,
|
||||||
|
})
|
||||||
|
.pipe(Schema.optional)
|
||||||
|
.annotate({ identifier: "ConfigProvidersModelExperimentalOver200K" })
|
||||||
|
|
||||||
|
const Cost = Schema.Struct({
|
||||||
|
input: Schema.Number,
|
||||||
|
output: Schema.Number,
|
||||||
|
cache: Cache,
|
||||||
|
experimentalOver200K: ExperimentalOver200K,
|
||||||
|
}).annotate({ identifier: "ConfigProvidersModelCost" })
|
||||||
|
|
||||||
|
const Limit = Schema.Struct({
|
||||||
|
context: Schema.Number,
|
||||||
|
input: Schema.optional(Schema.Number),
|
||||||
|
output: Schema.Number,
|
||||||
|
}).annotate({ identifier: "ConfigProvidersModelLimit" })
|
||||||
|
|
||||||
|
const Model = Schema.Struct({
|
||||||
|
id: Schema.String,
|
||||||
|
providerID: Schema.String,
|
||||||
|
api: ApiInfo,
|
||||||
|
name: Schema.String,
|
||||||
|
family: Schema.optional(Schema.String),
|
||||||
|
capabilities: Capabilities,
|
||||||
|
cost: Cost,
|
||||||
|
limit: Limit,
|
||||||
|
status: Schema.Union([
|
||||||
|
Schema.Literal("alpha"),
|
||||||
|
Schema.Literal("beta"),
|
||||||
|
Schema.Literal("deprecated"),
|
||||||
|
Schema.Literal("active"),
|
||||||
|
]),
|
||||||
|
options: Schema.Record(Schema.String, Schema.Unknown),
|
||||||
|
headers: Schema.Record(Schema.String, Schema.String),
|
||||||
|
release_date: Schema.String,
|
||||||
|
variants: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))),
|
||||||
|
}).annotate({ identifier: "ConfigProvidersModel" })
|
||||||
|
|
||||||
|
const ProviderInfo = Schema.Struct({
|
||||||
|
id: Schema.String,
|
||||||
|
name: Schema.String,
|
||||||
|
source: Schema.Union([
|
||||||
|
Schema.Literal("env"),
|
||||||
|
Schema.Literal("config"),
|
||||||
|
Schema.Literal("custom"),
|
||||||
|
Schema.Literal("api"),
|
||||||
|
]),
|
||||||
|
env: Schema.Array(Schema.String),
|
||||||
|
key: Schema.optional(Schema.String),
|
||||||
|
options: Schema.Record(Schema.String, Schema.Unknown),
|
||||||
|
models: Schema.Record(Schema.String, Schema.Unknown),
|
||||||
|
}).annotate({ identifier: "ConfigProvidersProvider" })
|
||||||
|
|
||||||
|
const Providers = Schema.Unknown
|
||||||
|
|
||||||
|
const root = "/experimental/httpapi/config"
|
||||||
|
|
||||||
|
const Api = HttpApi.make("config")
|
||||||
|
.add(
|
||||||
|
HttpApiGroup.make("config")
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.get("providers", `${root}/providers`, {
|
||||||
|
success: Providers,
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "config.providers",
|
||||||
|
summary: "List config providers",
|
||||||
|
description: "Get a list of all configured AI providers and their default models.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
title: "config",
|
||||||
|
description: "Experimental HttpApi config routes.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
title: "opencode experimental HttpApi",
|
||||||
|
version: "0.0.1",
|
||||||
|
description: "Experimental HttpApi surface for selected instance routes.",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ConfigLive = HttpApiBuilder.group(
|
||||||
|
Api,
|
||||||
|
"config",
|
||||||
|
Effect.fn("ConfigHttpApi.handlers")(function* (handlers) {
|
||||||
|
const svc = yield* Provider.Service
|
||||||
|
|
||||||
|
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
|
||||||
|
const all = mapValues(yield* svc.list(), (item) => item)
|
||||||
|
return Schema.decodeUnknownSync(Providers)(
|
||||||
|
JSON.parse(
|
||||||
|
JSON.stringify({
|
||||||
|
providers: Object.values(all),
|
||||||
|
default: mapValues(all, (item) => Provider.sort(Object.values(item.models))[0].id),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return handlers.handle("providers", providers)
|
||||||
|
}),
|
||||||
|
).pipe(Layer.provide(Provider.defaultLayer))
|
||||||
|
|
||||||
|
const web = lazy(() =>
|
||||||
|
HttpRouter.toWebHandler(
|
||||||
|
Layer.mergeAll(
|
||||||
|
AppLayer,
|
||||||
|
HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe(
|
||||||
|
Layer.provide(ConfigLive),
|
||||||
|
Layer.provide(HttpServer.layerServices),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
disableLogger: true,
|
||||||
|
memoMap,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const ConfigHttpApiHandler: Handler = (c, _next) => web().handler(c.req.raw)
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
import { ConfigHttpApiHandler } from "./config"
|
||||||
import { QuestionHttpApiHandler } from "./question"
|
import { QuestionHttpApiHandler } from "./question"
|
||||||
|
|
||||||
export const HttpApiRoutes = lazy(() =>
|
export const HttpApiRoutes = lazy(() =>
|
||||||
new Hono().all("/question", QuestionHttpApiHandler).all("/question/*", QuestionHttpApiHandler),
|
new Hono()
|
||||||
|
.all("/question", QuestionHttpApiHandler)
|
||||||
|
.all("/question/*", QuestionHttpApiHandler)
|
||||||
|
.all("/config", ConfigHttpApiHandler)
|
||||||
|
.all("/config/*", ConfigHttpApiHandler),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { Server } from "../../src/server/server"
|
||||||
|
import { Log } from "../../src/util/log"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
Log.init({ print: false })
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Instance.disposeAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("experimental config providers httpapi", () => {
|
||||||
|
test("lists config providers and serves docs", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
const app = Server.Default().app
|
||||||
|
const headers = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-opencode-directory": tmp.path,
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await app.request("/experimental/httpapi/config/providers", { headers })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(Array.isArray(body.providers)).toBe(true)
|
||||||
|
expect(typeof body.default).toBe("object")
|
||||||
|
|
||||||
|
const doc = await app.request("/experimental/httpapi/config/doc", { headers })
|
||||||
|
expect(doc.status).toBe(200)
|
||||||
|
const spec = await doc.json()
|
||||||
|
expect(spec.paths["/experimental/httpapi/config/providers"]?.get?.operationId).toBe("config.providers")
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user