feat: add well-known auth service
This commit is contained in:
parent
f98449c9b5
commit
c25128a6f5
11 changed files with 658 additions and 357 deletions
237
packages/core/src/auth-well-known.ts
Normal file
237
packages/core/src/auth-well-known.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
export * as AuthWellKnown from "./auth-well-known"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Option, Schema, SynchronizedRef } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { Global } from "./global"
|
||||
import { Substitution } from "./substitution"
|
||||
|
||||
export class Entry extends Schema.Class<Entry>("AuthWellKnown.Entry")({
|
||||
key: Schema.String,
|
||||
token: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("AuthWellKnown.FileWriteError", {
|
||||
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
|
||||
cause: Schema.Defect,
|
||||
}) {}
|
||||
|
||||
export class RemoteConfigError extends Schema.TaggedErrorClass<RemoteConfigError>()("AuthWellKnown.RemoteConfigError", {
|
||||
url: Schema.String,
|
||||
status: Schema.Number.pipe(Schema.optional),
|
||||
cause: Schema.Defect.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export type Error = FileWriteError | RemoteConfigError
|
||||
|
||||
const RemoteConfig = Schema.Struct({
|
||||
url: Schema.String,
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
})
|
||||
|
||||
export class Metadata extends Schema.Class<Metadata>("AuthWellKnown.Metadata")({
|
||||
auth: Schema.Struct({
|
||||
command: Schema.Array(Schema.String),
|
||||
env: Schema.String,
|
||||
}).pipe(Schema.optional),
|
||||
config: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
remote_config: RemoteConfig.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export type ConfigDocument = {
|
||||
url: string
|
||||
source: string
|
||||
dir: string
|
||||
content: unknown
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly all: () => Effect.Effect<Record<string, Entry>, Error>
|
||||
readonly get: (url: string) => Effect.Effect<Entry | undefined, Error>
|
||||
readonly set: (url: string, entry: Entry) => Effect.Effect<void, Error>
|
||||
readonly remove: (url: string) => Effect.Effect<void, Error>
|
||||
readonly configs: () => Effect.Effect<ConfigDocument[], Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AuthWellKnown") {}
|
||||
const decodeMetadata = Schema.decodeUnknownEffect(Metadata)
|
||||
const decodeRemoteConfig = Schema.decodeUnknownEffect(RemoteConfig)
|
||||
|
||||
function loadLegacyAuth(input: {
|
||||
fsys: AppFileSystem.Interface
|
||||
dataDir: string
|
||||
write: (data: Record<string, Entry>) => Effect.Effect<void, Error>
|
||||
}) {
|
||||
return Effect.gen(function* () {
|
||||
const decodeLegacy = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const decodeLegacyCredential = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("wellknown"),
|
||||
key: Schema.String,
|
||||
token: Schema.String,
|
||||
}),
|
||||
)
|
||||
const legacy = Object.fromEntries(
|
||||
Object.entries(
|
||||
Option.getOrElse(
|
||||
decodeLegacy(
|
||||
yield* input.fsys.readJson(path.join(input.dataDir, "auth.json")).pipe(Effect.orElseSucceed(() => null)),
|
||||
),
|
||||
() => ({}),
|
||||
),
|
||||
).flatMap(([url, value]) => {
|
||||
const decoded = Option.getOrUndefined(decodeLegacyCredential(value))
|
||||
return decoded ? [[url.replace(/\/+$/, ""), new Entry({ key: decoded.key, token: decoded.token })]] : []
|
||||
}),
|
||||
)
|
||||
if (Object.keys(legacy).length > 0) yield* input.write(legacy).pipe(Effect.ignore)
|
||||
return legacy
|
||||
})
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const substitution = yield* Substitution.Service
|
||||
const file = path.join(global.data, "well-known.json")
|
||||
const decodeEntries = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry))
|
||||
const normalizeUrl = (url: string) => url.replace(/\/+$/, "")
|
||||
|
||||
const write = (operation: "migrate" | "write", data: Record<string, Entry>) =>
|
||||
fsys.writeJson(file, data, 0o600).pipe(Effect.mapError((cause) => new FileWriteError({ operation, cause })))
|
||||
|
||||
const load: () => Effect.Effect<Record<string, Entry>> = Effect.fnUntraced(function* () {
|
||||
const current = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
|
||||
if (current && typeof current === "object")
|
||||
return Option.getOrElse(decodeEntries(current), () => ({}) as Record<string, Entry>)
|
||||
return yield* loadLegacyAuth({ fsys, dataDir: global.data, write: (data) => write("migrate", data) })
|
||||
})
|
||||
|
||||
const state = SynchronizedRef.makeUnsafe<Record<string, Entry>>(yield* load())
|
||||
|
||||
const metadata = Effect.fn("AuthWellKnown.metadata")(function* (url: string) {
|
||||
const normalized = normalizeUrl(url)
|
||||
const source = `${normalized}/.well-known/opencode`
|
||||
const response = yield* HttpClientRequest.get(source).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
http.execute,
|
||||
Effect.mapError((cause) => new RemoteConfigError({ url: source, cause })),
|
||||
)
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return yield* new RemoteConfigError({ url: source, status: response.status })
|
||||
}
|
||||
const metadata = yield* response.json.pipe(
|
||||
Effect.flatMap(decodeMetadata),
|
||||
Effect.mapError((cause) => new RemoteConfigError({ url: source, cause })),
|
||||
)
|
||||
return { url: normalized, source, dir: path.dirname(source), metadata }
|
||||
})
|
||||
|
||||
const remote = Effect.fn("AuthWellKnown.remote")(function* (input: { url: string; headers?: Record<string, string> }) {
|
||||
const response = yield* HttpClientRequest.get(input.url).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
input.headers ? HttpClientRequest.setHeaders(input.headers) : (request) => request,
|
||||
http.execute,
|
||||
Effect.mapError((cause) => new RemoteConfigError({ url: input.url, cause })),
|
||||
)
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return yield* new RemoteConfigError({ url: input.url, status: response.status })
|
||||
}
|
||||
return yield* response.json.pipe(Effect.mapError((cause) => new RemoteConfigError({ url: input.url, cause })))
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
all: Effect.fn("AuthWellKnown.all")(function* () {
|
||||
return yield* SynchronizedRef.get(state)
|
||||
}),
|
||||
|
||||
get: Effect.fn("AuthWellKnown.get")(function* (url) {
|
||||
return (yield* SynchronizedRef.get(state))[normalizeUrl(url)]
|
||||
}),
|
||||
|
||||
set: Effect.fn("AuthWellKnown.set")(function* (url, entry) {
|
||||
yield* SynchronizedRef.updateEffect(
|
||||
state,
|
||||
Effect.fnUntraced(function* (data) {
|
||||
const next = { ...data, [normalizeUrl(url)]: entry }
|
||||
yield* write("write", next)
|
||||
return next
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
remove: Effect.fn("AuthWellKnown.remove")(function* (url) {
|
||||
yield* SynchronizedRef.updateEffect(
|
||||
state,
|
||||
Effect.fnUntraced(function* (data) {
|
||||
const next = { ...data }
|
||||
delete next[url]
|
||||
delete next[normalizeUrl(url)]
|
||||
yield* write("write", next)
|
||||
return next
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
configs: Effect.fn("AuthWellKnown.configs")(function* () {
|
||||
const documents = yield* Effect.all(
|
||||
Object.entries(yield* SynchronizedRef.get(state)).map(([url, entry]) =>
|
||||
Effect.gen(function* () {
|
||||
const configs: ConfigDocument[] = []
|
||||
const response = yield* metadata(url)
|
||||
const env = { [entry.key]: entry.token }
|
||||
if (response.metadata.config) {
|
||||
configs.push({
|
||||
url: response.url,
|
||||
source: response.source,
|
||||
dir: response.dir,
|
||||
content: response.metadata.config,
|
||||
})
|
||||
}
|
||||
if (response.metadata.remote_config) {
|
||||
const remoteConfig = yield* substitution
|
||||
.substitute({
|
||||
text: JSON.stringify(response.metadata.remote_config),
|
||||
type: "virtual",
|
||||
dir: response.url,
|
||||
source: response.source,
|
||||
env,
|
||||
})
|
||||
.pipe(
|
||||
Effect.flatMap((text) =>
|
||||
Effect.try({
|
||||
try: () => JSON.parse(text) as unknown,
|
||||
catch: (cause) => new RemoteConfigError({ url: response.source, cause }),
|
||||
}),
|
||||
),
|
||||
Effect.flatMap(decodeRemoteConfig),
|
||||
Effect.mapError((cause) => new RemoteConfigError({ url: response.source, cause })),
|
||||
)
|
||||
configs.push({
|
||||
url: remoteConfig.url,
|
||||
source: remoteConfig.url,
|
||||
dir: path.dirname(remoteConfig.url),
|
||||
content: yield* remote({ url: remoteConfig.url, headers: remoteConfig.headers }),
|
||||
})
|
||||
}
|
||||
return configs
|
||||
}),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return documents.flat()
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Global.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Substitution.defaultLayer),
|
||||
)
|
||||
94
packages/core/src/substitution.ts
Normal file
94
packages/core/src/substitution.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
export * as Substitution from "./substitution"
|
||||
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
|
||||
type Source =
|
||||
| {
|
||||
type: "path"
|
||||
path: string
|
||||
}
|
||||
| {
|
||||
type: "virtual"
|
||||
source: string
|
||||
dir: string
|
||||
}
|
||||
|
||||
export type Input = Source & {
|
||||
text: string
|
||||
missing?: "error" | "empty"
|
||||
env?: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export class FileReferenceError extends Schema.TaggedErrorClass<FileReferenceError>()("Substitution.FileReferenceError", {
|
||||
source: Schema.String,
|
||||
token: Schema.String,
|
||||
resolved: Schema.String,
|
||||
cause: Schema.Defect,
|
||||
}) {}
|
||||
|
||||
export type Error = FileReferenceError
|
||||
|
||||
export interface Interface {
|
||||
readonly substitute: (input: Input) => Effect.Effect<string, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Substitution") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
return Service.of({
|
||||
substitute: Effect.fn("Substitution.substitute")(function* (input) {
|
||||
const missing = input.missing ?? "error"
|
||||
const text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return input.env?.[varName] ?? process.env[varName] ?? ""
|
||||
})
|
||||
|
||||
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
|
||||
if (!fileMatches.length) return text
|
||||
|
||||
const configDir = input.type === "path" ? path.dirname(input.path) : input.dir
|
||||
const configSource = input.type === "path" ? input.path : input.source
|
||||
let out = ""
|
||||
let cursor = 0
|
||||
|
||||
for (const match of fileMatches) {
|
||||
const token = match[0]
|
||||
const index = match.index!
|
||||
out += text.slice(cursor, index)
|
||||
|
||||
const lineStart = text.lastIndexOf("\n", index - 1) + 1
|
||||
const prefix = text.slice(lineStart, index).trimStart()
|
||||
if (prefix.startsWith("//")) {
|
||||
out += token
|
||||
cursor = index + token.length
|
||||
continue
|
||||
}
|
||||
|
||||
const reference = token.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||
const filepath = reference.startsWith("~/") ? path.join(os.homedir(), reference.slice(2)) : reference
|
||||
const resolved = path.isAbsolute(filepath) ? filepath : path.resolve(configDir, filepath)
|
||||
const content = yield* fs.readFileString(resolved).pipe(
|
||||
Effect.catch((cause) => {
|
||||
if (missing === "empty") return Effect.succeed("")
|
||||
return Effect.fail(new FileReferenceError({ source: configSource, token, resolved, cause }))
|
||||
}),
|
||||
)
|
||||
|
||||
out += JSON.stringify(content.trim()).slice(1, -1)
|
||||
cursor = index + token.length
|
||||
}
|
||||
|
||||
out += text.slice(cursor)
|
||||
return out
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
169
packages/core/test/auth-well-known.test.ts
Normal file
169
packages/core/test/auth-well-known.test.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Substitution } from "@opencode-ai/core/substitution"
|
||||
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
const withAuthWellKnown = <A, E, R>(dir: string, effect: Effect.Effect<A, E, R | AuthWellKnown.Service>) =>
|
||||
effect.pipe(
|
||||
Effect.provide(AuthWellKnown.layer),
|
||||
Effect.provide(AppFileSystem.defaultLayer),
|
||||
Effect.provide(Global.layerWith({ data: dir })),
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
Effect.provide(Substitution.defaultLayer),
|
||||
)
|
||||
|
||||
describe("AuthWellKnown", () => {
|
||||
it.live("stores well-known credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
|
||||
yield* withAuthWellKnown(
|
||||
tmp.path,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* AuthWellKnown.Service
|
||||
yield* auth.set("https://example.com/", new AuthWellKnown.Entry({ key: "TEST_TOKEN", token: "secret" }))
|
||||
}),
|
||||
)
|
||||
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "well-known.json")).json())).toEqual({
|
||||
"https://example.com": {
|
||||
key: "TEST_TOKEN",
|
||||
token: "secret",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("migrates legacy well-known auth records", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tmp.path, "auth.json"),
|
||||
JSON.stringify({
|
||||
"https://example.com": {
|
||||
type: "wellknown",
|
||||
key: "TEST_TOKEN",
|
||||
token: "secret",
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const entry = yield* withAuthWellKnown(
|
||||
tmp.path,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* AuthWellKnown.Service
|
||||
return yield* auth.get("https://example.com/")
|
||||
}),
|
||||
)
|
||||
|
||||
expect(entry).toEqual({
|
||||
key: "TEST_TOKEN",
|
||||
token: "secret",
|
||||
})
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "well-known.json")).json())).toEqual({
|
||||
"https://example.com": {
|
||||
key: "TEST_TOKEN",
|
||||
token: "secret",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads config documents", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tmp.path, "well-known.json"),
|
||||
JSON.stringify({
|
||||
"https://example.com": {
|
||||
key: "TEST_TOKEN",
|
||||
token: "secret",
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const originalFetch = Object.getOwnPropertyDescriptor(globalThis, "fetch")?.value as typeof fetch
|
||||
const originalToken = process.env.TEST_TOKEN
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const fakeFetch = Object.assign(
|
||||
(input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
||||
const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url
|
||||
if (url === "https://example.com/.well-known/opencode") {
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
config: { instructions: ["local"] },
|
||||
remote_config: {
|
||||
url: "https://remote.example.com/config",
|
||||
headers: {
|
||||
authorization: "Bearer {env:TEST_TOKEN}",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (url === "https://remote.example.com/config") {
|
||||
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer secret")
|
||||
return Promise.resolve(Response.json({ model: "remote/model" }))
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 404 }))
|
||||
},
|
||||
{ preconnect: originalFetch.preconnect },
|
||||
)
|
||||
Object.defineProperty(globalThis, "fetch", { value: fakeFetch, configurable: true, writable: true })
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
Object.defineProperty(globalThis, "fetch", { value: originalFetch, configurable: true, writable: true })
|
||||
if (originalToken === undefined) delete process.env.TEST_TOKEN
|
||||
else process.env.TEST_TOKEN = originalToken
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* withAuthWellKnown(
|
||||
tmp.path,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* AuthWellKnown.Service
|
||||
return yield* auth.configs()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(process.env.TEST_TOKEN).toBeUndefined()
|
||||
expect(result).toEqual([
|
||||
{
|
||||
url: "https://example.com",
|
||||
source: "https://example.com/.well-known/opencode",
|
||||
dir: "https://example.com/.well-known",
|
||||
content: { instructions: ["local"] },
|
||||
},
|
||||
{
|
||||
url: "https://remote.example.com/config",
|
||||
source: "https://remote.example.com/config",
|
||||
dir: "https://remote.example.com",
|
||||
content: { model: "remote/model" },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
49
packages/core/test/auth.test.ts
Normal file
49
packages/core/test/auth.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AuthV2 } from "@opencode-ai/core/auth"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
const withAuth = <A, E, R>(dir: string, effect: Effect.Effect<A, E, R | AuthV2.Service>) =>
|
||||
effect.pipe(
|
||||
Effect.provide(AuthV2.layer),
|
||||
Effect.provide(AppFileSystem.defaultLayer),
|
||||
Effect.provide(Global.layerWith({ data: dir })),
|
||||
)
|
||||
|
||||
describe("AuthV2", () => {
|
||||
it.live("stores api credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
|
||||
const account = yield* withAuth(
|
||||
tmp.path,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* AuthV2.Service
|
||||
return yield* auth.create({
|
||||
serviceID: AuthV2.ServiceID.make("anthropic"),
|
||||
credential: new AuthV2.ApiKeyCredential({ type: "api", key: "sk-test" }),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const active = yield* withAuth(
|
||||
tmp.path,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* AuthV2.Service
|
||||
return yield* auth.active(AuthV2.ServiceID.make("anthropic"))
|
||||
}),
|
||||
)
|
||||
|
||||
expect(active?.id).toBe(account.id)
|
||||
expect(active?.credential).toEqual({ type: "api", key: "sk-test" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue