Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow.
- [GroqCloud](docs/groqcloud.md) — API key for Enterprise Prometheus request/token/cache-hit metrics.
- [LLM Proxy](docs/llm-proxy.md) — API key + base URL for aggregate proxy quota stats and provider breakdowns.
- [ClawRouter](docs/clawrouter.md) — API key for monthly budget, spend, requests, tokens, and routed-provider usage.
- [Wayfinder](docs/wayfinder.md) — Local router gateway polling for health, per-route breakdown, savings, and decision latency.
- [LiteLLM](docs/litellm.md) — Virtual key + proxy URL for personal and team budget/spend tracking.
- [Deepgram](docs/deepgram.md) — API key usage summaries across speech, agent, token, and TTS metrics.
- [Poe](docs/poe.md) — API key for current point balance and recent points history.
Expand Down
11 changes: 11 additions & 0 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,17 @@ struct MenuDescriptor {
entries.append(.text("Routed providers: \(mix)", .secondary))
}
}
if let wayfinderUsage = snapshot.wayfinderUsage {
if let routed = wayfinderUsage.routedSummary {
entries.append(.text("Routed: \(routed)", .secondary))
}
if let saved = wayfinderUsage.savedSummary {
entries.append(.text("Saved: \(saved)", .secondary))
}
if let avgDecision = wayfinderUsage.avgDecisionSummary {
entries.append(.text("Avg decision: \(avgDecision)", .secondary))
}
}
if let poeUsage = snapshot.poeUsage, !poeUsage.daily.isEmpty {
Self.appendPoeUsageSummary(entries: &entries, usage: poeUsage)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ enum ProviderImplementationRegistry {
case .chutes: ChutesProviderImplementation()
case .crossmodel: CrossModelProviderImplementation()
case .clawrouter: ClawRouterProviderImplementation()
case .wayfinder: WayfinderProviderImplementation()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import CodexBarCore
import Foundation

struct WayfinderProviderImplementation: ProviderImplementation {
let id: UsageProvider = .wayfinder

@MainActor
func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
ProviderPresentation { _ in "api" }
}

@MainActor
func observeSettings(_ settings: SettingsStore) {
_ = settings.wayfinderGatewayURL
}

@MainActor
func isAvailable(context _: ProviderAvailabilityContext) -> Bool {
// The gateway's read-only API needs no credentials; enabling the provider is the opt-in.
true
}

@MainActor
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[
ProviderSettingsFieldDescriptor(
id: "wayfinder-gateway-url",
title: "Gateway URL",
subtitle: "Local Wayfinder gateway. Read-only polling of health, routing split, and " +
"savings — prompts are never read or sent.",
kind: .plain,
placeholder: WayfinderSettingsReader.defaultBaseURL.absoluteString,
binding: context.stringBinding(\.wayfinderGatewayURL),
actions: [],
isVisible: nil,
onActivate: nil),
]
}
}
13 changes: 13 additions & 0 deletions Sources/CodexBar/Providers/Wayfinder/WayfinderSettingsStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import CodexBarCore
import Foundation

extension SettingsStore {
var wayfinderGatewayURL: String {
get { self.configSnapshot.providerConfig(for: .wayfinder)?.sanitizedEnterpriseHost ?? "" }
set {
self.updateProviderConfig(provider: .wayfinder) { entry in
entry.enterpriseHost = self.normalizedConfigValue(newValue)
}
}
}
}
5 changes: 5 additions & 0 deletions Sources/CodexBar/Resources/ProviderIcon-wayfinder.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,7 @@ extension UsageStore {
.deepgram: "Deepgram debug log not yet implemented",
.chutes: "Chutes debug log not yet implemented",
.clawrouter: "ClawRouter debug log not yet implemented",
.wayfinder: "Wayfinder debug log not yet implemented",
]
let buildText = {
switch provider {
Expand Down Expand Up @@ -1228,7 +1229,7 @@ extension UsageStore {
.sakana, .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .qoder,
.stepfun,
.bedrock, .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes,
.clawrouter:
.clawrouter, .wayfinder:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
}
Expand Down
26 changes: 26 additions & 0 deletions Sources/CodexBarCLI/CLIRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ enum CLIRenderer {
self.appendMiMoBalanceLine(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendCrossModelUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendClawRouterUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendWayfinderUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendDeepgramLines(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendAmpBalanceLines(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendDevinOverageBalanceLine(
Expand Down Expand Up @@ -100,6 +101,7 @@ enum CLIRenderer {
self.appendMiMoBalanceLine(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendCrossModelUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendClawRouterUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendWayfinderUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendDeepgramLines(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendAmpBalanceLines(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendDevinOverageBalanceLine(
Expand Down Expand Up @@ -484,6 +486,7 @@ enum CLIRenderer {
}
self.appendCrossModelUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendClawRouterUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendWayfinderUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendDeepgramLines(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendAmpBalanceLines(snapshot: snapshot, useColor: context.useColor, lines: &lines)
self.appendDevinOverageBalanceLine(
Expand Down Expand Up @@ -707,6 +710,29 @@ enum CLIRenderer {
}
}

private static func appendWayfinderUsageLines(
snapshot: UsageSnapshot,
useColor: Bool,
lines: inout [String])
{
guard let usage = snapshot.wayfinderUsage else { return }

var gatewayValue = "\(usage.gatewayStatus) · \(usage.modelCountLabel)"
if usage.offline { gatewayValue += " · offline" }
if usage.dryRun { gatewayValue += " · dry run" }
lines.append(self.labelValueLine("Gateway", value: gatewayValue, useColor: useColor))

if let routed = usage.routedSummary {
lines.append(self.labelValueLine("Routed", value: routed, useColor: useColor))
}
if let saved = usage.savedSummary {
lines.append(self.labelValueLine("Saved", value: saved, useColor: useColor))
}
if let avgDecision = usage.avgDecisionSummary {
lines.append(self.labelValueLine("Avg decision", value: avgDecision, useColor: useColor))
}
}

private enum CrossModelMetric {
case tokens
case requests
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBarCore/Config/CodexBarConfigValidation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ public enum CodexBarConfigValidator {

private static func providerSupportsEnterpriseHost(_ provider: UsageProvider) -> Bool {
switch provider {
case .azureopenai, .copilot, .kimi, .llmproxy, .litellm, .clawrouter:
case .azureopenai, .copilot, .kimi, .llmproxy, .litellm, .clawrouter, .wayfinder:
true
default:
false
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ public enum ProviderConfigEnvironment {
LiteLLMSettingsReader.baseURLEnvironmentKey
case .clawrouter:
ClawRouterSettingsReader.baseURLEnvironmentKey
case .wayfinder:
WayfinderSettingsReader.baseURLEnvironmentKey
default:
nil
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.

enum CodexParserHash {
static let value = "d43faceb2100c02d"
static let value = "1745966d77d9dae8"
}
1 change: 1 addition & 0 deletions Sources/CodexBarCore/Providers/ProviderDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ public enum ProviderDescriptorRegistry {
.chutes: ChutesProviderDescriptor.descriptor,
.crossmodel: CrossModelProviderDescriptor.descriptor,
.clawrouter: ClawRouterProviderDescriptor.descriptor,
.wayfinder: WayfinderProviderDescriptor.descriptor,
]
private static let bootstrap: Void = {
for provider in UsageProvider.allCases {
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBarCore/Providers/Providers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case chutes
case crossmodel
case clawrouter
case wayfinder
}

// swiftformat:enable sortDeclarations
Expand Down Expand Up @@ -120,6 +121,7 @@ public enum IconStyle: String, Sendable, CaseIterable {
case chutes
case crossmodel
case clawrouter
case wayfinder
case combined
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Foundation

public enum WayfinderProviderDescriptor {
public static let descriptor: ProviderDescriptor = Self.makeDescriptor()

static func makeDescriptor() -> ProviderDescriptor {
ProviderDescriptor(
id: .wayfinder,
metadata: ProviderMetadata(
id: .wayfinder,
displayName: "Wayfinder",
sessionLabel: "Savings",
weeklyLabel: "Requests",
opusLabel: nil,
supportsOpus: false,
supportsCredits: false,
creditsHint: "",
toggleTitle: "Show Wayfinder usage",
cliName: "wayfinder",
defaultEnabled: false,
dashboardURL: "http://127.0.0.1:8088/router",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the configured Wayfinder URL for the dashboard

When a user overrides the Wayfinder Gateway URL (for example to http://localhost:9099), fetches use that override via the projected environment, but the menu's Usage Dashboard action falls through StatusItemController+Actions.dashboardURL to this hard-coded metadata URL for Wayfinder. In that configuration the dashboard button opens the default 127.0.0.1:8088 instance, which is wrong or unreachable even though polling succeeds against the configured gateway.

Useful? React with 👍 / 👎.

statusPageURL: nil),
branding: ProviderBranding(
iconStyle: .wayfinder,
iconResourceName: "ProviderIcon-wayfinder",
color: ProviderColor(red: 16 / 255, green: 163 / 255, blue: 127 / 255)),
tokenCost: ProviderTokenCostConfig(
supportsTokenCost: false,
noDataMessage: { "Wayfinder savings are reported by its local gateway." }),
fetchPlan: ProviderFetchPlan(
sourceModes: [.auto, .api],
pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [WayfinderGatewayFetchStrategy()] })),
cli: ProviderCLIConfig(
name: "wayfinder",
aliases: ["wayfinder-router"],
versionDetector: nil))
}
}

struct WayfinderGatewayFetchStrategy: ProviderFetchStrategy {
let id = "wayfinder.api"
let kind: ProviderFetchKind = .apiToken

func isAvailable(_: ProviderFetchContext) async -> Bool {
// The gateway's read-only endpoints are unauthenticated; the provider is
// opt-in (defaultEnabled: false), so no credential gates availability.
true
}

func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
try WayfinderSettingsReader.validateEndpointOverride(environment: context.env)
let usage = try await WayfinderUsageFetcher.fetchUsage(
baseURL: WayfinderSettingsReader.baseURL(environment: context.env))
return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
}

func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation

public enum WayfinderSettingsError: LocalizedError, Equatable, Sendable {
case invalidEndpointOverride(String)

public var errorDescription: String? {
switch self {
case let .invalidEndpointOverride(key):
"Wayfinder gateway URL override \(key) is invalid. Use an HTTPS URL, or plain HTTP for " +
"loopback addresses only, without embedded credentials."
}
}
}

public enum WayfinderSettingsReader {
public static let baseURLEnvironmentKey = "WAYFINDER_GATEWAY_URL"
public static let defaultBaseURL = URL(string: "http://127.0.0.1:8088")!

public static func baseURL(
environment: [String: String] = ProcessInfo.processInfo.environment) -> URL
{
guard let raw = self.cleaned(environment[self.baseURLEnvironmentKey]) else {
return self.defaultBaseURL
}
// Loopback HTTP is allowed because the gateway is a local service; the default
// base URL is plain HTTP on 127.0.0.1. Non-loopback hosts must use HTTPS.
return ProviderEndpointOverrideValidator().validatedURLAllowingLoopbackHTTP(raw) ?? self.defaultBaseURL
}

public static func validateEndpointOverride(
environment: [String: String] = ProcessInfo.processInfo.environment) throws
{
guard let raw = self.cleaned(environment[self.baseURLEnvironmentKey]) else { return }
guard ProviderEndpointOverrideValidator().validatedURLAllowingLoopbackHTTP(raw) != nil else {
throw WayfinderSettingsError.invalidEndpointOverride(self.baseURLEnvironmentKey)
}
}

static func cleaned(_ raw: String?) -> String? {
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
}
if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
(value.hasPrefix("'") && value.hasSuffix("'"))
{
value = String(value.dropFirst().dropLast())
}
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
Loading