@jevvy/permissions 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/engine.d.ts CHANGED
@@ -1,5 +1,7 @@
1
- import type { JevClient, NoulAnswer } from "./core.ts";
2
- import type { ApprovalQuestions } from "./questions.ts";
1
+ import { Effect, Schema } from "effect";
2
+ import { NoulAnswer } from "./core.ts";
3
+ import type { JevClient, JevProviderFailure } from "./core.ts";
4
+ import { ApprovalQuestions } from "./questions.ts";
3
5
  export type PermissionEffect = "allow" | "ask";
4
6
  export interface ResourceJudgment {
5
7
  readonly resource: string;
@@ -14,19 +16,25 @@ export type PermissionReview = {
14
16
  readonly effect: "ask";
15
17
  readonly reason: "judged" | "unavailable" | "empty";
16
18
  readonly judgments: readonly ResourceJudgment[];
19
+ readonly failure?: JevProviderFailure;
17
20
  };
18
21
  export interface PermissionRequest {
19
22
  readonly action: string;
20
23
  readonly resources: readonly string[];
21
24
  }
22
25
  export interface PermissionReviewer {
23
- readonly review: (request: PermissionRequest, signal?: AbortSignal) => Promise<PermissionReview>;
24
- readonly dispose: () => Promise<void>;
26
+ readonly review: (request: PermissionRequest) => Effect.Effect<PermissionReview>;
25
27
  }
26
28
  export interface PermissionReviewerOptions {
27
29
  readonly questions?: ApprovalQuestions;
28
30
  readonly timeoutMs?: number;
29
31
  readonly cacheCapacity?: number;
30
32
  }
33
+ declare const ReviewerConfigurationError_base: Schema.Class<ReviewerConfigurationError, Schema.TaggedStruct<"ReviewerConfigurationError", {
34
+ readonly message: Schema.String;
35
+ }>, import("effect/Cause").YieldableError>;
36
+ export declare class ReviewerConfigurationError extends ReviewerConfigurationError_base {
37
+ }
31
38
  export declare const permissionEffectOf: (answers: Readonly<Record<string, NoulAnswer>>, questions: ApprovalQuestions) => PermissionEffect;
32
- export declare const createPermissionReviewer: (client: JevClient, options?: PermissionReviewerOptions) => Promise<PermissionReviewer>;
39
+ export declare const createPermissionReviewer: (client: JevClient, options?: PermissionReviewerOptions | undefined) => Effect.Effect<PermissionReviewer, ReviewerConfigurationError, never>;
40
+ export {};
package/dist/engine.js CHANGED
@@ -1,18 +1,35 @@
1
- import { Cache, Duration, Effect, Exit } from "effect";
2
- import { defaultApprovalQuestions, toNoulQuestions } from "./questions.js";
3
- class JudgmentUnavailable extends Error {
4
- name = "JudgmentUnavailable";
1
+ import { Cache, Clock, Duration, Effect, Exit, Ref, Schema } from "effect";
2
+ import { isJevProviderError, JevProviderFailure as JevProviderFailureSchema, NoulAnswer, providerFailureOf, } from "./core.js";
3
+ import { ApprovalQuestions, defaultApprovalQuestions, toNoulQuestions } from "./questions.js";
4
+ const RATE_LIMIT_COOLDOWN_MS = 60_000;
5
+ const EXHAUSTED_COOLDOWN_MS = 5 * 60_000;
6
+ const cooldownMs = (failure) => {
7
+ if (failure.retryAfterMs !== undefined)
8
+ return failure.retryAfterMs;
9
+ if (failure.kind === "rate-limited")
10
+ return RATE_LIMIT_COOLDOWN_MS;
11
+ if (failure.kind === "credits-exhausted" || failure.kind === "quota-exhausted") {
12
+ return EXHAUSTED_COOLDOWN_MS;
13
+ }
14
+ return undefined;
15
+ };
16
+ class JudgmentUnavailable extends Schema.TaggedError()("JudgmentUnavailable", {
17
+ message: Schema.String,
18
+ failure: Schema.optional(JevProviderFailureSchema),
19
+ }) {
20
+ }
21
+ export class ReviewerConfigurationError extends Schema.TaggedError()("ReviewerConfigurationError", { message: Schema.String }) {
5
22
  }
6
23
  const answerPasses = (answer, question) => question.threshold.direction === "atMost"
7
24
  ? answer <= question.threshold.value
8
25
  : answer >= question.threshold.value;
9
- const validAnswer = (answer) => answer?.type === "noul" && Number.isFinite(answer.noul) && answer.noul >= 0 && answer.noul <= 1;
26
+ const validAnswer = Schema.is(NoulAnswer);
10
27
  export const permissionEffectOf = (answers, questions) => {
11
28
  let effect = "allow";
12
29
  for (const [key, question] of Object.entries(questions)) {
13
30
  const answer = answers[key];
14
31
  if (!validAnswer(answer))
15
- throw new JudgmentUnavailable(`missing valid answer for ${key}`);
32
+ throw new JudgmentUnavailable({ message: `missing valid answer for ${key}` });
16
33
  if (!answerPasses(answer.noul, question))
17
34
  effect = "ask";
18
35
  }
@@ -20,7 +37,7 @@ export const permissionEffectOf = (answers, questions) => {
20
37
  };
21
38
  const judgeResult = (resource, result, questions) => {
22
39
  if (result.model.trim().length === 0)
23
- throw new JudgmentUnavailable("missing model identifier");
40
+ throw new JudgmentUnavailable({ message: "missing model identifier" });
24
41
  return {
25
42
  resource,
26
43
  effect: permissionEffectOf(result.answers, questions),
@@ -28,70 +45,100 @@ const judgeResult = (resource, result, questions) => {
28
45
  answers: result.answers,
29
46
  };
30
47
  };
31
- const validateQuestions = (questions) => {
32
- const entries = Object.entries(questions);
33
- if (entries.length === 0)
34
- throw new Error("at least one approval question is required");
35
- for (const [key, question] of entries) {
36
- if (key.length === 0)
37
- throw new Error("approval question keys cannot be empty");
38
- if (question.instructions.trim().length === 0)
39
- throw new Error(`approval question ${key} has no instructions`);
40
- if (!Number.isFinite(question.threshold.value) || question.threshold.value < 0 || question.threshold.value > 1) {
41
- throw new Error(`approval question ${key} has an invalid threshold`);
42
- }
48
+ const validateQuestions = (questions) => Schema.decodeUnknownEffect(ApprovalQuestions, { onExcessProperty: "error" })(questions).pipe(Effect.asVoid, Effect.mapError(() => new ReviewerConfigurationError({ message: "invalid approval questions" })));
49
+ const cacheKey = (action, resource) => `${action.length}:${action}${resource}`;
50
+ const unavailable = (error) => {
51
+ if (error instanceof JudgmentUnavailable)
52
+ return error;
53
+ if (isJevProviderError(error)) {
54
+ return new JudgmentUnavailable({ message: error.message, failure: providerFailureOf(error) });
43
55
  }
56
+ return new JudgmentUnavailable({ message: String(error) });
44
57
  };
45
- const cacheKey = (action, resource) => `${action.length}:${action}${resource}`;
46
- const abortReason = (signal) => signal.reason instanceof Error
47
- ? signal.reason
48
- : new DOMException("The operation was aborted", "AbortError");
49
- const isAborted = (signal) => signal?.aborted === true;
50
- export const createPermissionReviewer = async (client, options = {}) => {
58
+ export const createPermissionReviewer = Effect.fn("PermissionReviewer.create")(function* (client, options = {}) {
51
59
  const questions = options.questions ?? defaultApprovalQuestions;
52
60
  const timeoutMs = options.timeoutMs ?? 5_000;
53
61
  const capacity = options.cacheCapacity ?? 512;
54
- validateQuestions(questions);
55
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
56
- throw new Error("timeoutMs must be positive");
57
- if (!Number.isInteger(capacity) || capacity <= 0)
58
- throw new Error("cacheCapacity must be a positive integer");
62
+ yield* validateQuestions(questions);
63
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
64
+ return yield* new ReviewerConfigurationError({ message: "timeoutMs must be positive" });
65
+ }
66
+ if (!Number.isInteger(capacity) || capacity <= 0) {
67
+ return yield* new ReviewerConfigurationError({ message: "cacheCapacity must be a positive integer" });
68
+ }
59
69
  const noulQuestions = toNoulQuestions(questions);
60
- const cache = await Effect.runPromise(Cache.makeWith((key) => {
70
+ const providerCooldown = yield* Ref.make(undefined);
71
+ const activeProviderFailure = Effect.fn("PermissionReviewer.activeProviderFailure")(function* () {
72
+ const now = yield* Clock.currentTimeMillis;
73
+ return yield* Ref.modify(providerCooldown, (current) => {
74
+ if (current === undefined || current.untilMs <= now)
75
+ return [undefined, undefined];
76
+ return [current.failure, current];
77
+ });
78
+ });
79
+ const recordProviderCooldown = Effect.fn("PermissionReviewer.recordProviderCooldown")(function* (failure) {
80
+ const duration = cooldownMs(failure);
81
+ if (duration === undefined)
82
+ return;
83
+ const now = yield* Clock.currentTimeMillis;
84
+ const next = { failure, untilMs: now + duration };
85
+ yield* Ref.update(providerCooldown, (current) => current === undefined || current.untilMs < next.untilMs ? next : current);
86
+ });
87
+ const judgeResource = Effect.fn("PermissionReviewer.judgeResource")(function* (key) {
61
88
  const separator = key.indexOf(":");
62
89
  const actionLength = Number(key.slice(0, separator));
63
90
  const payload = key.slice(separator + 1);
64
91
  const action = payload.slice(0, actionLength);
65
92
  const resource = payload.slice(actionLength);
66
- return Effect.tryPromise((signal) => client.evaluate({ state: { action, resource }, questions: noulQuestions }, signal)).pipe(Effect.timeout(timeoutMs), Effect.map((result) => judgeResult(resource, result, questions)), Effect.mapError((error) => new JudgmentUnavailable(String(error))));
67
- }, {
93
+ const disabled = yield* activeProviderFailure();
94
+ if (disabled !== undefined) {
95
+ return yield* new JudgmentUnavailable({ message: disabled.message, failure: disabled });
96
+ }
97
+ return yield* client.evaluate({ state: { action, resource }, questions: noulQuestions }).pipe(Effect.timeout(timeoutMs), Effect.flatMap((result) => Effect.try({
98
+ try: () => judgeResult(resource, result, questions),
99
+ catch: (cause) => unavailable(cause instanceof Error ? cause : new Error(String(cause))),
100
+ })), Effect.mapError(unavailable), Effect.tapError((error) => error.failure === undefined
101
+ ? Effect.succeed(undefined)
102
+ : recordProviderCooldown(error.failure)));
103
+ });
104
+ const cache = yield* Cache.makeWith(judgeResource, {
68
105
  capacity,
69
106
  timeToLive: (exit) => Exit.isSuccess(exit) ? Duration.infinity : Duration.zero,
70
- }));
107
+ });
71
108
  return {
72
- async review(request, signal) {
109
+ review: Effect.fn("PermissionReviewer.review")(function* (request) {
73
110
  if (request.resources.length === 0)
74
111
  return { effect: "ask", reason: "empty", judgments: [] };
112
+ const disabled = yield* activeProviderFailure();
113
+ if (disabled !== undefined) {
114
+ return { effect: "ask", reason: "unavailable", judgments: [], failure: disabled };
115
+ }
75
116
  const judgments = [];
76
117
  for (const resource of request.resources) {
77
- if (isAborted(signal))
78
- throw abortReason(signal);
79
- try {
80
- const judgment = await Effect.runPromise(Cache.get(cache, cacheKey(request.action, resource)), signal === undefined ? undefined : { signal });
81
- judgments.push(judgment);
82
- if (judgment.effect === "ask")
83
- return { effect: "ask", reason: "judged", judgments };
84
- }
85
- catch {
86
- if (isAborted(signal))
87
- throw abortReason(signal);
88
- return { effect: "ask", reason: "unavailable", judgments };
118
+ const result = yield* Cache.get(cache, cacheKey(request.action, resource)).pipe(Effect.match({
119
+ onFailure: (error) => ({ kind: "failure", error }),
120
+ onSuccess: (judgment) => ({ kind: "success", judgment }),
121
+ }));
122
+ if (result.kind === "failure") {
123
+ if (result.error.failure !== undefined) {
124
+ return {
125
+ effect: "ask",
126
+ reason: "unavailable",
127
+ judgments,
128
+ failure: result.error.failure,
129
+ };
130
+ }
131
+ return {
132
+ effect: "ask",
133
+ reason: "unavailable",
134
+ judgments,
135
+ };
89
136
  }
137
+ judgments.push(result.judgment);
138
+ if (result.judgment.effect === "ask")
139
+ return { effect: "ask", reason: "judged", judgments };
90
140
  }
91
141
  return { effect: "allow", judgments };
92
- },
93
- async dispose() {
94
- await client.dispose?.();
95
- },
142
+ }),
96
143
  };
97
- };
144
+ });
@@ -1,17 +1,7 @@
1
- import type { ProviderApiKeys, ProviderPreference } from "../config.ts";
1
+ import { Effect } from "effect";
2
+ import type { JevClient } from "../core.ts";
3
+ import type { ProviderApiKeys, SelectedProvider } from "../providers.ts";
2
4
  export declare const OPENCODE_INTEGRATION = "opencode";
3
- export type ProviderCredential = {
4
- readonly kind: "zen";
5
- readonly key: string;
6
- readonly model: string;
7
- readonly origin: "opencode" | "config";
8
- } | {
9
- readonly kind: "typesafe";
10
- readonly key: string;
11
- readonly origin: "config";
12
- } | {
13
- readonly kind: "unavailable";
14
- };
15
5
  export type StoredCredential = {
16
6
  readonly type: "key";
17
7
  readonly key: string;
@@ -21,8 +11,8 @@ export type StoredCredential = {
21
11
  readonly access: string;
22
12
  };
23
13
  export interface CredentialPorts<C> {
24
- readonly activeIntegration: (id: string) => Promise<C | undefined>;
25
- readonly resolveIntegration: (connection: C) => Promise<StoredCredential | undefined>;
14
+ readonly activeIntegration: (id: string) => Effect.Effect<C | undefined>;
15
+ readonly resolveIntegration: (connection: C) => Effect.Effect<StoredCredential | undefined>;
26
16
  readonly readEnv: (name: string) => string | undefined;
27
17
  }
28
18
  export interface ConnectionLike {
@@ -30,4 +20,4 @@ export interface ConnectionLike {
30
20
  readonly envName?: string;
31
21
  }
32
22
  export declare const credentialToken: (credential: StoredCredential | undefined) => string | undefined;
33
- export declare const resolveCredential: <C>(ports: CredentialPorts<C>, preference: ProviderPreference, describe: (connection: C) => ConnectionLike, apiKeys?: ProviderApiKeys) => Promise<ProviderCredential>;
23
+ export declare const selectOpenCodeProvider: <C>(ports: CredentialPorts<C>, preference: "auto" | "openrouter" | "typesafe" | "vercel" | "zen", describe: (connection: C) => ConnectionLike, apiKeys: ProviderApiKeys, createOpenCodeClient: (apiKey: string) => JevClient) => Effect.Effect<SelectedProvider | undefined, never, never>;
@@ -1,12 +1,12 @@
1
- import { DEFAULT_ZEN_MODEL } from "../core.js";
2
- import { Redacted } from "effect";
1
+ import { Effect } from "effect";
2
+ import { createProvider, selectConfiguredProvider } from "../providers.js";
3
3
  export const OPENCODE_INTEGRATION = "opencode";
4
4
  export const credentialToken = (credential) => {
5
5
  const token = credential?.type === "key" ? credential.key : credential?.access;
6
6
  return token !== undefined && token.trim().length > 0 ? token : undefined;
7
7
  };
8
- const integrationCredential = async (ports, id, describe) => {
9
- const connection = await ports.activeIntegration(id);
8
+ const resolveOpenCodeCredential = Effect.fn("Credentials.resolveOpenCodeCredential")(function* (ports, describe) {
9
+ const connection = yield* ports.activeIntegration(OPENCODE_INTEGRATION);
10
10
  if (connection === undefined)
11
11
  return undefined;
12
12
  const like = describe(connection);
@@ -14,31 +14,16 @@ const integrationCredential = async (ports, id, describe) => {
14
14
  const key = ports.readEnv(like.envName);
15
15
  return key === undefined ? undefined : { type: "key", key };
16
16
  }
17
- const resolved = await ports.resolveIntegration(connection);
17
+ const resolved = yield* ports.resolveIntegration(connection);
18
18
  return credentialToken(resolved) === undefined ? undefined : resolved;
19
- };
20
- const zenCandidate = async (ports, describe, configuredKey) => {
21
- const opencode = await integrationCredential(ports, OPENCODE_INTEGRATION, describe);
22
- const opencodeToken = credentialToken(opencode);
23
- if (opencodeToken !== undefined) {
24
- return { kind: "zen", key: opencodeToken, model: DEFAULT_ZEN_MODEL, origin: "opencode" };
25
- }
26
- if (configuredKey !== undefined) {
27
- return { kind: "zen", key: Redacted.value(configuredKey), model: DEFAULT_ZEN_MODEL, origin: "config" };
28
- }
29
- return { kind: "unavailable" };
30
- };
31
- const typeSafeCandidate = (configuredKey) => {
32
- if (configuredKey !== undefined) {
33
- return { kind: "typesafe", key: Redacted.value(configuredKey), origin: "config" };
34
- }
35
- return { kind: "unavailable" };
36
- };
37
- export const resolveCredential = async (ports, preference, describe, apiKeys = {}) => {
38
- if (preference === "zen")
39
- return zenCandidate(ports, describe, apiKeys.zen);
40
- if (preference === "typesafe")
41
- return typeSafeCandidate(apiKeys.typesafe);
42
- const zen = await zenCandidate(ports, describe, apiKeys.zen);
43
- return zen.kind === "unavailable" ? typeSafeCandidate(apiKeys.typesafe) : zen;
44
- };
19
+ });
20
+ export const selectOpenCodeProvider = Effect.fn("Credentials.selectOpenCodeProvider")(function* (ports, preference, describe, apiKeys, createOpenCodeClient) {
21
+ const configured = selectConfiguredProvider(preference, apiKeys);
22
+ if (preference !== "auto" && preference !== "zen")
23
+ return configured;
24
+ const credential = yield* resolveOpenCodeCredential(ports, describe);
25
+ const apiKey = credentialToken(credential);
26
+ return apiKey === undefined
27
+ ? configured
28
+ : createProvider("zen", apiKey, createOpenCodeClient(apiKey));
29
+ });
@@ -1,5 +1,7 @@
1
- import type { PermissionEvaluation } from "@opencode/plugin/promise/permission";
1
+ import type { PermissionEvaluation } from "@opencode/plugin/effect/permission";
2
+ import { Effect } from "effect";
2
3
  import type { PermissionReviewer } from "../engine.ts";
4
+ import type { JevProviderFailure } from "../core.ts";
3
5
  export interface EvaluationEvent {
4
6
  readonly sessionID: string;
5
7
  readonly action: string;
@@ -13,6 +15,7 @@ export interface EvaluateOptions {
13
15
  readonly effect: "allow" | "ask";
14
16
  readonly reason: "judged" | "unavailable" | "empty";
15
17
  readonly resources: number;
18
+ readonly failure?: JevProviderFailure;
16
19
  }) => void;
17
20
  }
18
- export declare const createEvaluate: (reviewer: PermissionReviewer | undefined, options: EvaluateOptions) => ((event: EvaluationEvent) => Promise<void>);
21
+ export declare const createEvaluate: (reviewer: PermissionReviewer | undefined, options: EvaluateOptions) => ((event: EvaluationEvent) => Effect.Effect<void>);
@@ -1,7 +1,7 @@
1
- import { Option, Schema } from "effect";
1
+ import { Effect, Option, Schema } from "effect";
2
2
  const CommandMetadata = Schema.Struct({ command: Schema.NonEmptyString });
3
3
  const commandFrom = (metadata) => Schema.decodeUnknownOption(CommandMetadata)(metadata).pipe(Option.map(({ command }) => command.trim()), Option.filter((command) => command.length > 0), Option.getOrUndefined);
4
- export const createEvaluate = (reviewer, options) => async (event) => {
4
+ export const createEvaluate = (reviewer, options) => Effect.fn("OpenCode.evaluatePermission")(function* (event) {
5
5
  if (event.effect !== "ask" || event.action !== "shell" || reviewer === undefined)
6
6
  return;
7
7
  const command = commandFrom(event.metadata);
@@ -12,9 +12,14 @@ export const createEvaluate = (reviewer, options) => async (event) => {
12
12
  const resources = command === undefined
13
13
  ? event.resources
14
14
  : [...new Set([...event.resources, command])];
15
- const review = await reviewer.review({ action: event.action, resources });
15
+ const review = yield* reviewer.review({ action: event.action, resources });
16
16
  const reason = review.effect === "ask" ? review.reason : "judged";
17
- options.report?.({ effect: review.effect, reason, resources: resources.length });
17
+ if (review.effect === "ask" && review.failure !== undefined) {
18
+ options.report?.({ effect: review.effect, reason, resources: resources.length, failure: review.failure });
19
+ }
20
+ else {
21
+ options.report?.({ effect: review.effect, reason, resources: resources.length });
22
+ }
18
23
  if (review.effect === "allow")
19
24
  event.effect = "allow";
20
- };
25
+ });
@@ -1,3 +1,3 @@
1
- import { Plugin } from "@opencode/plugin";
2
- declare const _default: Plugin.Plugin;
1
+ import { Plugin } from "@opencode/plugin/effect";
2
+ declare const _default: Plugin.Plugin<import("effect/Scope").Scope>;
3
3
  export default _default;
@@ -1,10 +1,10 @@
1
- import { createTypeSafeClient, createZenClient, DEFAULT_TYPESAFE_MODEL } from "../core.js";
2
- import { Plugin } from "@opencode/plugin";
1
+ import { createZenClient, JevProviderError } from "../core.js";
2
+ import { Plugin } from "@opencode/plugin/effect";
3
3
  import { Effect } from "effect";
4
4
  import { loadJevvyConfig } from "../config.js";
5
5
  import { createPermissionReviewer } from "../engine.js";
6
6
  import { createEvaluate } from "./evaluate.js";
7
- import { credentialToken, OPENCODE_INTEGRATION, resolveCredential, } from "./credentials.js";
7
+ import { credentialToken, OPENCODE_INTEGRATION, selectOpenCodeProvider, } from "./credentials.js";
8
8
  const describeConnection = (connection) => connection.type === "env"
9
9
  ? { kind: "env", envName: connection.name }
10
10
  : { kind: "credential" };
@@ -18,74 +18,65 @@ const storedCredentialFrom = (credential) => {
18
18
  };
19
19
  export default Plugin.define({
20
20
  id: "jevvy.permissions",
21
- async setup(ctx) {
22
- const permissionConfig = await Effect.runPromise(loadJevvyConfig());
21
+ effect: Effect.fn("JevvyPlugin.setup")(function* (ctx) {
22
+ const permissionConfig = yield* loadJevvyConfig();
23
23
  if (permissionConfig.kind === "invalid") {
24
- console.warn("[jevvy] jevvy.jsonc failed validation, auto-approval is disabled", permissionConfig.message);
24
+ yield* Effect.sync(() => {
25
+ console.warn("[jevvy] jevvy.jsonc failed validation, auto-approval is disabled", permissionConfig.message);
26
+ });
25
27
  }
26
28
  const ports = {
27
29
  activeIntegration: (id) => ctx.integration.connection.active(id),
28
- resolveIntegration: async (connection) => {
29
- try {
30
- return storedCredentialFrom(await ctx.integration.connection.resolve(connection));
31
- }
32
- catch {
33
- return undefined;
34
- }
35
- },
30
+ resolveIntegration: (connection) => ctx.integration.connection.resolve(connection).pipe(Effect.map(storedCredentialFrom), Effect.catch(() => Effect.succeed(undefined))),
36
31
  readEnv: (name) => process.env[name],
37
32
  };
38
33
  const apiKeys = permissionConfig.kind === "invalid" ? {} : permissionConfig.apiKeys;
39
- const provider = permissionConfig.kind === "invalid" ? "auto" : permissionConfig.provider;
40
- const credential = await resolveCredential(ports, provider, describeConnection, apiKeys);
41
- let client;
42
- if (credential.kind === "typesafe") {
43
- client = createTypeSafeClient(credential.key);
44
- }
45
- if (credential.kind === "zen") {
46
- if (credential.origin === "opencode") {
47
- client = {
48
- async evaluate(request, signal) {
49
- const connection = await ctx.integration.connection.active(OPENCODE_INTEGRATION);
50
- const stored = connection === undefined
51
- ? undefined
52
- : storedCredentialFrom(await ctx.integration.connection.resolve(connection));
53
- const token = credentialToken(stored);
54
- if (token === undefined)
55
- throw new Error("OpenCode login is unavailable");
56
- return createZenClient(token, credential.model).evaluate(request, signal);
57
- },
58
- };
59
- }
60
- else {
61
- client = createZenClient(credential.key, credential.model);
62
- }
63
- }
34
+ const preference = permissionConfig.kind === "invalid" ? "auto" : permissionConfig.provider;
35
+ const selected = permissionConfig.kind === "invalid"
36
+ ? undefined
37
+ : yield* selectOpenCodeProvider(ports, preference, describeConnection, apiKeys, () => ({
38
+ evaluate: Effect.fn("JevvyPlugin.evaluateWithOpenCodeCredential")(function* (request) {
39
+ const connection = yield* ctx.integration.connection.active(OPENCODE_INTEGRATION);
40
+ const stored = connection === undefined
41
+ ? undefined
42
+ : yield* ctx.integration.connection.resolve(connection).pipe(Effect.map(storedCredentialFrom), Effect.catch(() => Effect.succeed(undefined)));
43
+ const token = credentialToken(stored);
44
+ if (token === undefined) {
45
+ return yield* new JevProviderError({
46
+ provider: "zen",
47
+ kind: "authentication",
48
+ message: "OpenCode login is unavailable",
49
+ });
50
+ }
51
+ return yield* createZenClient(token).evaluate(request);
52
+ }),
53
+ }));
64
54
  const questions = permissionConfig.kind === "custom" ? permissionConfig.questions : undefined;
65
- const reviewer = client === undefined || permissionConfig.kind === "invalid"
55
+ const reviewer = selected === undefined || permissionConfig.kind === "invalid"
66
56
  ? undefined
67
- : await createPermissionReviewer(client, { questions });
68
- if (client === undefined && permissionConfig.kind !== "invalid") {
69
- console.warn("[jevvy] no provider credential, native permission prompts remain unchanged");
57
+ : yield* createPermissionReviewer(selected.client, { questions }).pipe(Effect.orDie);
58
+ if (selected === undefined && permissionConfig.kind !== "invalid") {
59
+ yield* Effect.sync(() => {
60
+ console.warn("[jevvy] no provider credential, native permission prompts remain unchanged");
61
+ });
70
62
  }
71
63
  const evaluate = createEvaluate(reviewer, {
72
- report: (entry) => console.info("[jevvy] permission review", entry),
64
+ report: (entry) => {
65
+ if (entry.failure !== undefined) {
66
+ console.warn("[jevvy] provider failure, native permission prompt remains unchanged", entry);
67
+ return;
68
+ }
69
+ console.info("[jevvy] permission review", entry);
70
+ },
73
71
  });
74
- const permissions = await ctx.permission.hook("evaluate", evaluate);
75
- let model;
76
- if (credential.kind === "zen")
77
- model = credential.model;
78
- if (credential.kind === "typesafe")
79
- model = DEFAULT_TYPESAFE_MODEL;
80
- console.info("[jevvy] loaded", {
81
- provider: credential.kind,
82
- model,
83
- questions: permissionConfig.kind === "custom" ? "custom" : "calibrated-defaults",
84
- autoApproval: reviewer === undefined ? "disabled" : "enabled",
72
+ yield* ctx.permission.hook("evaluate", evaluate);
73
+ yield* Effect.sync(() => {
74
+ console.info("[jevvy] loaded", {
75
+ provider: selected?.provider ?? "unavailable",
76
+ model: selected?.model,
77
+ questions: permissionConfig.kind === "custom" ? "custom" : "calibrated-defaults",
78
+ autoApproval: reviewer === undefined ? "disabled" : "enabled",
79
+ });
85
80
  });
86
- return async () => {
87
- await permissions.dispose();
88
- await reviewer?.dispose();
89
- };
90
- },
81
+ }),
91
82
  });
@@ -0,0 +1,15 @@
1
+ import type { JevClient, JevProvider as JevProviderType } from "./core.ts";
2
+ import { Redacted, Schema } from "effect";
3
+ export declare const ProviderPreference: Schema.Literals<readonly ["auto", "zen", "typesafe", "openrouter", "vercel"]>;
4
+ export type ProviderPreference = Schema.Schema.Type<typeof ProviderPreference>;
5
+ export type ProviderApiKeys = {
6
+ readonly [Provider in JevProviderType]?: Redacted.Redacted<string>;
7
+ };
8
+ export interface SelectedProvider {
9
+ readonly provider: JevProviderType;
10
+ readonly model: string;
11
+ readonly client: JevClient;
12
+ readonly redact: (text: string) => string;
13
+ }
14
+ export declare const createProvider: (provider: JevProviderType, apiKey: string, client?: JevClient) => SelectedProvider;
15
+ export declare const selectConfiguredProvider: (preference: ProviderPreference, apiKeys: ProviderApiKeys) => SelectedProvider | undefined;
@@ -0,0 +1,43 @@
1
+ import { createOpenRouterClient, createTypeSafeClient, createVercelClient, createZenClient, DEFAULT_OPENROUTER_MODEL, DEFAULT_TYPESAFE_MODEL, DEFAULT_VERCEL_MODEL, DEFAULT_ZEN_MODEL, JevProvider, } from "./core.js";
2
+ import { Redacted, Schema } from "effect";
3
+ export const ProviderPreference = Schema.Literals(["auto", ...JevProvider.literals]);
4
+ const providerDefinitions = {
5
+ zen: {
6
+ automaticPriority: 0,
7
+ model: DEFAULT_ZEN_MODEL,
8
+ createClient: createZenClient,
9
+ },
10
+ typesafe: {
11
+ automaticPriority: 1,
12
+ model: DEFAULT_TYPESAFE_MODEL,
13
+ createClient: createTypeSafeClient,
14
+ },
15
+ openrouter: {
16
+ automaticPriority: 2,
17
+ model: DEFAULT_OPENROUTER_MODEL,
18
+ createClient: createOpenRouterClient,
19
+ },
20
+ vercel: {
21
+ automaticPriority: 3,
22
+ model: DEFAULT_VERCEL_MODEL,
23
+ createClient: createVercelClient,
24
+ },
25
+ };
26
+ const automaticProviders = [...JevProvider.literals].sort((left, right) => providerDefinitions[left].automaticPriority - providerDefinitions[right].automaticPriority);
27
+ export const createProvider = (provider, apiKey, client = providerDefinitions[provider].createClient(apiKey)) => ({
28
+ provider,
29
+ model: providerDefinitions[provider].model,
30
+ client,
31
+ redact: (text) => apiKey.length === 0 ? text : text.replaceAll(apiKey, "[redacted]"),
32
+ });
33
+ export const selectConfiguredProvider = (preference, apiKeys) => {
34
+ const candidates = preference === "auto"
35
+ ? automaticProviders
36
+ : [preference];
37
+ for (const provider of candidates) {
38
+ const apiKey = apiKeys[provider];
39
+ if (apiKey !== undefined)
40
+ return createProvider(provider, Redacted.value(apiKey));
41
+ }
42
+ return undefined;
43
+ };
@@ -1,12 +1,38 @@
1
- import type { NoulQuestion } from "./core.ts";
2
- export interface ApprovalThreshold {
3
- readonly direction: "atLeast" | "atMost";
4
- readonly value: number;
1
+ import { Schema } from "effect";
2
+ import { NoulQuestion } from "./core.ts";
3
+ export declare const ApprovalThreshold: Schema.Struct<{
4
+ readonly direction: Schema.Literals<readonly ["atLeast", "atMost"]>;
5
+ readonly value: Schema.Finite;
6
+ }>;
7
+ export interface ApprovalThreshold extends Schema.Schema.Type<typeof ApprovalThreshold> {
5
8
  }
6
- export interface ApprovalQuestion extends NoulQuestion {
7
- readonly threshold: ApprovalThreshold;
9
+ export declare const ApprovalQuestion: Schema.Struct<{
10
+ readonly type: Schema.Literal<"noul">;
11
+ readonly instructions: Schema.NonEmptyString;
12
+ readonly criteria: Schema.optionalKey<Schema.Struct<{
13
+ readonly false: Schema.NonEmptyString;
14
+ readonly true: Schema.NonEmptyString;
15
+ }>>;
16
+ readonly threshold: Schema.Struct<{
17
+ readonly direction: Schema.Literals<readonly ["atLeast", "atMost"]>;
18
+ readonly value: Schema.Finite;
19
+ }>;
20
+ }>;
21
+ export interface ApprovalQuestion extends Schema.Schema.Type<typeof ApprovalQuestion> {
8
22
  }
9
- export type ApprovalQuestions = Readonly<Record<string, ApprovalQuestion>>;
23
+ export declare const ApprovalQuestions: Schema.$Record<Schema.NonEmptyString, Schema.Struct<{
24
+ readonly type: Schema.Literal<"noul">;
25
+ readonly instructions: Schema.NonEmptyString;
26
+ readonly criteria: Schema.optionalKey<Schema.Struct<{
27
+ readonly false: Schema.NonEmptyString;
28
+ readonly true: Schema.NonEmptyString;
29
+ }>>;
30
+ readonly threshold: Schema.Struct<{
31
+ readonly direction: Schema.Literals<readonly ["atLeast", "atMost"]>;
32
+ readonly value: Schema.Finite;
33
+ }>;
34
+ }>>;
35
+ export type ApprovalQuestions = Schema.Schema.Type<typeof ApprovalQuestions>;
10
36
  /**
11
37
  * The shipped inquiry set is calibrated as one unit with its thresholds and
12
38
  * pinned models. Rewording or overriding any entry creates a custom policy.