faberun 0.26.3 → 0.27.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.
@@ -0,0 +1,253 @@
1
+ //! A loopback relay between one fx worker and its OpenAI-compatible provider.
2
+ //! fx 0.0.11 keeps only prompt and completion totals from a Chat Completions
3
+ //! response, so the cache counters the provider returns never reach its JSON
4
+ //! or its ACP usage. The relay forwards every byte unchanged and reads the
5
+ //! `usage` object on the way back: measured 2026-09-24 over 24 fx requests to
6
+ //! api.deepseek.com, 268,288 of 283,372 input tokens (94.7%) were cache hits
7
+ //! that fx reported as ordinary input. It also sees the HTTP status fx turns
8
+ //! into prose, which is the only reliable quota and rate-limit signal.
9
+ //!
10
+ //! One relay per runner, bound to 127.0.0.1 on an ephemeral port, so parallel
11
+ //! workers never share a meter. One thread per connection: fx keeps a
12
+ //! connection alive between requests and may open another beside it.
13
+
14
+ const std = @import("std");
15
+ const Io = std.Io;
16
+ const usage = @import("usage.zig");
17
+
18
+ /// Bytes of an error body kept for classification; the rest is the provider's prose.
19
+ const error_body_limit = 2048;
20
+
21
+ pub const Relayed = struct {
22
+ status: u16,
23
+ usage: ?usage.Usage,
24
+ /// Owned by the relay's allocator; the callback copies what it keeps.
25
+ error_body: ?[]const u8,
26
+ retry_after_ms: ?u64,
27
+ };
28
+
29
+ pub const Callback = struct {
30
+ context: *anyopaque,
31
+ function: *const fn (context: *anyopaque, request: Relayed) void,
32
+ };
33
+
34
+ pub const Relay = struct {
35
+ gpa: std.mem.Allocator,
36
+ io: Io,
37
+ upstream: []const u8,
38
+ client: std.http.Client,
39
+ server: Io.net.Server,
40
+ callback: Callback,
41
+
42
+ pub fn port(self: *const Relay) u16 {
43
+ return self.server.socket.address.getPort();
44
+ }
45
+
46
+ /// Closing the listening socket ends the accept loop; connection threads
47
+ /// die with the process.
48
+ pub fn stop(self: *Relay) void {
49
+ self.server.deinit(self.io);
50
+ }
51
+ };
52
+
53
+ /// Listen on an ephemeral loopback port and serve on a detached thread.
54
+ pub fn start(gpa: std.mem.Allocator, io: Io, upstream: []const u8, callback: Callback) !*Relay {
55
+ const relay = try gpa.create(Relay);
56
+ const address = try Io.net.IpAddress.parse("127.0.0.1", 0);
57
+ relay.* = .{
58
+ .gpa = gpa,
59
+ .io = io,
60
+ .upstream = std.mem.trimEnd(u8, upstream, "/"),
61
+ .client = .{ .allocator = gpa, .io = io },
62
+ .server = try address.listen(io, .{ .reuse_address = true }),
63
+ .callback = callback,
64
+ };
65
+ const thread = try std.Thread.spawn(.{}, acceptLoop, .{relay});
66
+ thread.detach();
67
+ return relay;
68
+ }
69
+
70
+ fn acceptLoop(relay: *Relay) void {
71
+ while (true) {
72
+ const stream = relay.server.accept(relay.io) catch return;
73
+ const thread = std.Thread.spawn(.{}, serveConnection, .{ relay, stream }) catch {
74
+ stream.close(relay.io);
75
+ continue;
76
+ };
77
+ thread.detach();
78
+ }
79
+ }
80
+
81
+ fn serveConnection(relay: *Relay, stream: Io.net.Stream) void {
82
+ defer stream.close(relay.io);
83
+ var receive_buffer: [64 * 1024]u8 = undefined;
84
+ var send_buffer: [16 * 1024]u8 = undefined;
85
+ var reader = stream.reader(relay.io, &receive_buffer);
86
+ var writer = stream.writer(relay.io, &send_buffer);
87
+ var server = std.http.Server.init(&reader.interface, &writer.interface);
88
+ while (true) {
89
+ var request = server.receiveHead() catch return;
90
+ var arena_state = std.heap.ArenaAllocator.init(relay.gpa);
91
+ defer arena_state.deinit();
92
+ const relayed = relayOne(relay, arena_state.allocator(), &request) catch |err| {
93
+ // The provider was unreachable or the stream broke mid-body; fx
94
+ // sees the connection drop and the meter records a 502.
95
+ relay.callback.function(relay.callback.context, .{ .status = 502, .usage = null, .error_body = @errorName(err), .retry_after_ms = null });
96
+ return;
97
+ };
98
+ relay.callback.function(relay.callback.context, relayed);
99
+ if (!request.head.keep_alive) return;
100
+ }
101
+ }
102
+
103
+ fn relayOne(relay: *Relay, arena: std.mem.Allocator, request: *std.http.Server.Request) !Relayed {
104
+ // Head strings are invalidated once the body reader starts; copy first.
105
+ const method = request.head.method;
106
+ const url = try std.mem.concat(arena, u8, &.{ relay.upstream, request.head.target });
107
+ var authorization: ?[]const u8 = null;
108
+ const content_type: ?[]const u8 = if (request.head.content_type) |value| try arena.dupe(u8, value) else null;
109
+ // Every other header goes through as sent: the Codex endpoint needs
110
+ // `chatgpt-account-id`, `originator` and its session ids, and a relay that
111
+ // picks headers by name breaks the first provider it did not know.
112
+ var extra: std.ArrayList(std.http.Header) = .empty;
113
+ var headers = request.iterateHeaders();
114
+ while (headers.next()) |header| {
115
+ if (std.ascii.eqlIgnoreCase(header.name, "authorization")) {
116
+ authorization = try arena.dupe(u8, header.value);
117
+ } else if (!isTransportHeader(header.name)) {
118
+ try extra.append(arena, .{ .name = try arena.dupe(u8, header.name), .value = try arena.dupe(u8, header.value) });
119
+ }
120
+ }
121
+ var body_buffer: [8 * 1024]u8 = undefined;
122
+ const body_reader = try request.readerExpectContinue(&body_buffer);
123
+ const body = try body_reader.allocRemaining(arena, .unlimited);
124
+
125
+ var upstream = try relay.client.request(method, try std.Uri.parse(url), .{
126
+ .redirect_behavior = .unhandled,
127
+ .headers = .{
128
+ .authorization = if (authorization) |value| .{ .override = value } else .omit,
129
+ .content_type = if (content_type) |value| .{ .override = value } else .omit,
130
+ // Identity, so the relay can read the usage frame it forwards.
131
+ .accept_encoding = .{ .override = "identity" },
132
+ },
133
+ .extra_headers = extra.items,
134
+ });
135
+ defer upstream.deinit();
136
+ if (method.requestHasBody()) {
137
+ upstream.transfer_encoding = .{ .content_length = body.len };
138
+ try upstream.sendBodyComplete(body);
139
+ } else {
140
+ try upstream.sendBodiless();
141
+ }
142
+ var response = try upstream.receiveHead(&.{});
143
+
144
+ const status: u16 = @intFromEnum(response.head.status);
145
+ const response_type: ?[]const u8 = if (response.head.content_type) |value| try arena.dupe(u8, value) else null;
146
+ var retry_after: ?[]const u8 = null;
147
+ var response_headers = response.head.iterateHeaders();
148
+ while (response_headers.next()) |header| {
149
+ if (std.ascii.eqlIgnoreCase(header.name, "retry-after")) retry_after = try arena.dupe(u8, header.value);
150
+ }
151
+ var forwarded: [2]std.http.Header = undefined;
152
+ var forwarded_len: usize = 0;
153
+ if (response_type) |value| {
154
+ forwarded[forwarded_len] = .{ .name = "content-type", .value = value };
155
+ forwarded_len += 1;
156
+ }
157
+ if (retry_after) |value| {
158
+ forwarded[forwarded_len] = .{ .name = "retry-after", .value = value };
159
+ forwarded_len += 1;
160
+ }
161
+
162
+ var reply_buffer: [16 * 1024]u8 = undefined;
163
+ var reply = try request.respondStreaming(&reply_buffer, .{ .respond_options = .{
164
+ .status = response.head.status,
165
+ .extra_headers = forwarded[0..forwarded_len],
166
+ } });
167
+ var transfer_buffer: [64 * 1024]u8 = undefined;
168
+ const source = response.reader(&transfer_buffer);
169
+ // Measured 2026-09-26: the Codex endpoint streams SSE with no
170
+ // content-type at all, so a missing header is decided by the first bytes.
171
+ var streaming = if (response_type) |value| std.mem.indexOf(u8, value, "text/event-stream") != null else false;
172
+ var sniffed = response_type != null;
173
+ const ok = status < 400;
174
+ var pending: std.ArrayList(u8) = .empty;
175
+ var whole: std.ArrayList(u8) = .empty;
176
+ var metered: ?usage.Usage = null;
177
+ while (true) {
178
+ const data = source.peekGreedy(1) catch |err| switch (err) {
179
+ error.EndOfStream => break,
180
+ error.ReadFailed => return response.bodyErr() orelse err,
181
+ };
182
+ try reply.writer.writeAll(data);
183
+ try reply.writer.flush();
184
+ try reply.flush();
185
+ if (!sniffed) {
186
+ sniffed = true;
187
+ streaming = looksLikeEventStream(data);
188
+ }
189
+ if (streaming) {
190
+ try pending.appendSlice(arena, data);
191
+ while (std.mem.indexOfScalar(u8, pending.items, '\n')) |newline| {
192
+ if (usage.fromStreamLine(arena, pending.items[0..newline])) |found| metered = found;
193
+ pending.replaceRangeAssumeCapacity(0, newline + 1, &.{});
194
+ }
195
+ } else if (ok or whole.items.len < error_body_limit) {
196
+ try whole.appendSlice(arena, data);
197
+ }
198
+ source.toss(data.len);
199
+ }
200
+ try reply.end();
201
+ if (streaming) {
202
+ if (usage.fromStreamLine(arena, pending.items)) |found| metered = found;
203
+ } else if (ok) {
204
+ metered = usage.fromDocument(arena, whole.items);
205
+ }
206
+ return .{
207
+ .status = status,
208
+ .usage = metered,
209
+ .error_body = if (ok) null else whole.items[0..@min(whole.items.len, error_body_limit)],
210
+ .retry_after_ms = if (retry_after) |value| retryAfterMs(value) else null,
211
+ };
212
+ }
213
+
214
+ /// Whether a body with no content-type starts as a server-sent event stream.
215
+ fn looksLikeEventStream(bytes: []const u8) bool {
216
+ const head = std.mem.trimStart(u8, bytes, " \t\r\n");
217
+ return std.mem.startsWith(u8, head, "data:") or std.mem.startsWith(u8, head, "event:") or std.mem.startsWith(u8, head, ":");
218
+ }
219
+
220
+ /// Headers the relay's own connections own, plus the two it sets itself.
221
+ fn isTransportHeader(name: []const u8) bool {
222
+ const owned = [_][]const u8{ "host", "connection", "keep-alive", "content-length", "transfer-encoding", "accept-encoding", "expect", "te", "upgrade", "content-type" };
223
+ for (owned) |candidate| {
224
+ if (std.ascii.eqlIgnoreCase(name, candidate)) return true;
225
+ }
226
+ return false;
227
+ }
228
+
229
+ /// Seconds form only; the HTTP-date form carries no reset the controller
230
+ /// could not also get from waiting out its failover edge.
231
+ fn retryAfterMs(value: []const u8) ?u64 {
232
+ const seconds = std.fmt.parseInt(u64, std.mem.trim(u8, value, " "), 10) catch return null;
233
+ return if (seconds > 0) seconds * 1000 else null;
234
+ }
235
+
236
+ test "transport headers stay with the relay and provider headers pass through" {
237
+ try std.testing.expect(isTransportHeader("Content-Length"));
238
+ try std.testing.expect(isTransportHeader("accept-encoding"));
239
+ try std.testing.expect(!isTransportHeader("chatgpt-account-id"));
240
+ try std.testing.expect(!isTransportHeader("accept"));
241
+ }
242
+
243
+ test "a body without content-type is sniffed as SSE or JSON" {
244
+ try std.testing.expect(looksLikeEventStream("event: response.created\ndata: {}"));
245
+ try std.testing.expect(looksLikeEventStream("\ndata: {}"));
246
+ try std.testing.expect(!looksLikeEventStream("{\"usage\":{}}"));
247
+ }
248
+
249
+ test "retry-after in seconds becomes milliseconds" {
250
+ try std.testing.expectEqual(@as(?u64, 30_000), retryAfterMs("30"));
251
+ try std.testing.expectEqual(@as(?u64, null), retryAfterMs("0"));
252
+ try std.testing.expectEqual(@as(?u64, null), retryAfterMs("Wed, 21 Oct 2026 07:28:00 GMT"));
253
+ }
@@ -0,0 +1,112 @@
1
+ //! Provider usage in the ledger's shape, where `inputTokens` excludes the
2
+ //! cached prefix. DeepSeek reports the split directly
3
+ //! (`prompt_cache_hit_tokens`, `prompt_cache_miss_tokens`); OpenAI, vLLM and
4
+ //! Z.ai report `prompt_tokens_details.cached_tokens` inside `prompt_tokens`;
5
+ //! the Responses API reports `input_tokens_details.cached_tokens` inside
6
+ //! `input_tokens`.
7
+ //! fx 0.0.11 keeps neither, which is why the relay reads them itself.
8
+
9
+ const std = @import("std");
10
+
11
+ pub const Usage = struct {
12
+ inputTokens: u64 = 0,
13
+ outputTokens: u64 = 0,
14
+ cacheReadInputTokens: u64 = 0,
15
+
16
+ pub fn add(self: *Usage, other: Usage) void {
17
+ self.inputTokens += other.inputTokens;
18
+ self.outputTokens += other.outputTokens;
19
+ self.cacheReadInputTokens += other.cacheReadInputTokens;
20
+ }
21
+ };
22
+
23
+ fn count(object: std.json.ObjectMap, key: []const u8) ?u64 {
24
+ const value = object.get(key) orelse return null;
25
+ return switch (value) {
26
+ .integer => |n| if (n >= 0) @intCast(n) else null,
27
+ else => null,
28
+ };
29
+ }
30
+
31
+ /// The usage a Chat Completions body or stream frame carries, or null.
32
+ pub fn fromUsageObject(value: std.json.Value) ?Usage {
33
+ const object = switch (value) {
34
+ .object => |o| o,
35
+ else => return null,
36
+ };
37
+ // The Responses API (fx's Codex provider) names the same counters
38
+ // `input_tokens`, `output_tokens` and `input_tokens_details.cached_tokens`.
39
+ if (count(object, "input_tokens")) |input| {
40
+ var cached: u64 = 0;
41
+ if (object.get("input_tokens_details")) |details| switch (details) {
42
+ .object => |d| cached = count(d, "cached_tokens") orelse 0,
43
+ else => {},
44
+ };
45
+ return .{ .inputTokens = input -| cached, .outputTokens = count(object, "output_tokens") orelse 0, .cacheReadInputTokens = cached };
46
+ }
47
+ const prompt = count(object, "prompt_tokens");
48
+ const miss = count(object, "prompt_cache_miss_tokens");
49
+ if (prompt == null and miss == null) return null;
50
+ var hit = count(object, "prompt_cache_hit_tokens");
51
+ if (hit == null) {
52
+ if (object.get("prompt_tokens_details")) |details| switch (details) {
53
+ .object => |d| hit = count(d, "cached_tokens"),
54
+ else => {},
55
+ };
56
+ }
57
+ const cached = hit orelse 0;
58
+ return .{
59
+ .inputTokens = miss orelse (prompt orelse 0) -| cached,
60
+ .outputTokens = count(object, "completion_tokens") orelse 0,
61
+ .cacheReadInputTokens = cached,
62
+ };
63
+ }
64
+
65
+ /// The usage in one JSON document (a whole response body or one SSE payload).
66
+ pub fn fromDocument(arena: std.mem.Allocator, text: []const u8) ?Usage {
67
+ const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, text, .{}) catch return null;
68
+ const object = switch (parsed) {
69
+ .object => |o| o,
70
+ else => return null,
71
+ };
72
+ if (object.get("usage")) |found| return fromUsageObject(found);
73
+ // A Responses stream reports usage once, on `response.completed`, inside
74
+ // the response object.
75
+ const response = switch (object.get("response") orelse return null) {
76
+ .object => |o| o,
77
+ else => return null,
78
+ };
79
+ return fromUsageObject(response.get("usage") orelse return null);
80
+ }
81
+
82
+ /// The usage in one SSE line, or null for any other line.
83
+ pub fn fromStreamLine(arena: std.mem.Allocator, line: []const u8) ?Usage {
84
+ const trimmed = std.mem.trim(u8, line, " \t\r");
85
+ if (!std.mem.startsWith(u8, trimmed, "data:")) return null;
86
+ const payload = std.mem.trim(u8, trimmed[5..], " \t");
87
+ if (std.mem.eql(u8, payload, "[DONE]")) return null;
88
+ return fromDocument(arena, payload);
89
+ }
90
+
91
+ test "both provider shapes become the ledger's shape" {
92
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
93
+ defer arena_state.deinit();
94
+ const arena = arena_state.allocator();
95
+ try std.testing.expectEqual(Usage{ .inputTokens = 100, .outputTokens = 50, .cacheReadInputTokens = 900 }, fromStreamLine(arena,
96
+ \\data: {"choices":[],"usage":{"prompt_tokens":1000,"completion_tokens":50,"prompt_cache_hit_tokens":900,"prompt_cache_miss_tokens":100}}
97
+ ).?);
98
+ try std.testing.expectEqual(Usage{ .inputTokens = 400, .outputTokens = 50, .cacheReadInputTokens = 600 }, fromDocument(arena,
99
+ \\{"usage":{"prompt_tokens":1000,"completion_tokens":50,"prompt_tokens_details":{"cached_tokens":600}}}
100
+ ).?);
101
+ try std.testing.expectEqual(Usage{ .inputTokens = 10, .outputTokens = 2 }, fromDocument(arena,
102
+ \\{"usage":{"prompt_tokens":10,"completion_tokens":2}}
103
+ ).?);
104
+ try std.testing.expectEqual(Usage{ .inputTokens = 300, .outputTokens = 40, .cacheReadInputTokens = 700 }, fromStreamLine(arena,
105
+ \\data: {"type":"response.completed","response":{"usage":{"input_tokens":1000,"input_tokens_details":{"cached_tokens":700},"output_tokens":40}}}
106
+ ).?);
107
+ try std.testing.expectEqual(@as(?Usage, null), fromStreamLine(arena, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}"));
108
+ try std.testing.expectEqual(@as(?Usage, null), fromStreamLine(arena, "data: [DONE]"));
109
+ try std.testing.expectEqual(@as(?Usage, null), fromStreamLine(arena, "data: {\"choices\":[{\"delta\":{}}]}"));
110
+ try std.testing.expectEqual(@as(?Usage, null), fromStreamLine(arena, ": keep-alive"));
111
+ try std.testing.expectEqual(@as(?Usage, null), fromDocument(arena, "{\"usage\":{\"completion_tokens\":2}}"));
112
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The file-effect boundary for an fx worker, decided per ACP permission
3
+ * request. fx in `ask` mode asks its client before every shell command and
4
+ * file mutation, so the contract's `sandbox` value becomes a decision this
5
+ * module makes, not a mode fx is trusted to enforce.
6
+ */
7
+
8
+ import { isAbsolute, relative, resolve } from "node:path";
9
+
10
+ /**
11
+ * The permission verdict for one ACP tool call. Shell commands run in every
12
+ * mode, as they do under dsh's `workspace-write`: the worktree, not a shell
13
+ * parser, is what bounds them. File mutations are decided by path.
14
+ *
15
+ * @param {string} sandbox
16
+ * @param {string} workspace real path of the worker's cwd
17
+ * @param {Record<string, any>} toolCall
18
+ * @returns {{allow: boolean, reason: string|null}}
19
+ */
20
+ export function permissionVerdict(sandbox, workspace, toolCall) {
21
+ if (sandbox === "danger-full-access") return { allow: true, reason: null };
22
+ const kind = typeof toolCall.kind === "string" ? toolCall.kind : "other";
23
+ if (kind === "read" || kind === "search" || kind === "think" || kind === "execute") return { allow: true, reason: null };
24
+ if (kind !== "edit" && kind !== "delete" && kind !== "move") {
25
+ return { allow: false, reason: `${kind} tools need danger-full-access` };
26
+ }
27
+ if (sandbox === "read-only") return { allow: false, reason: "the sandbox is read-only" };
28
+ const path = toolCall.rawInput?.path;
29
+ if (typeof path !== "string" || !path) return { allow: false, reason: "a file mutation named no path" };
30
+ const inside = relative(workspace, resolve(workspace, path));
31
+ if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
32
+ return { allow: false, reason: `${path} is outside the workspace` };
33
+ }
34
+ return { allow: true, reason: null };
35
+ }