{"id":50871436,"url":"https://github.com/runcycles/cycles-spring-ai-starter","last_synced_at":"2026-06-15T05:34:52.994Z","repository":{"id":357383445,"uuid":"1236692961","full_name":"runcycles/cycles-spring-ai-starter","owner":"runcycles","description":"Spring AI starter for Cycles — runtime budget and action authority for Spring AI agents","archived":false,"fork":false,"pushed_at":"2026-06-10T10:23:10.000Z","size":369,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-15T05:34:50.992Z","etag":null,"topics":["action-control","agent-governance","agent-runtime","agent-safety","agentic-ai","ai-agents","ai-governance","budget","budget-control","chat-client","cost-control","enterprise","java","llm","llmops","multi-tenant","runtime-authority","spring-ai","spring-boot"],"latest_commit_sha":null,"homepage":"https://runcycles.io","language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/runcycles.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":"AUDIT.md","citation":null,"codeowners":".github/CODEOWNERS","security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":"MAINTAINERS.md","copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-12T13:37:28.000Z","updated_at":"2026-06-10T10:23:12.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/runcycles/cycles-spring-ai-starter","commit_stats":null,"previous_names":["runcycles/cycles-spring-ai-starter"],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/runcycles/cycles-spring-ai-starter","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/runcycles%2Fcycles-spring-ai-starter","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/runcycles%2Fcycles-spring-ai-starter/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/runcycles%2Fcycles-spring-ai-starter/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/runcycles%2Fcycles-spring-ai-starter/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/runcycles","download_url":"https://codeload.github.com/runcycles/cycles-spring-ai-starter/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/runcycles%2Fcycles-spring-ai-starter/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34349925,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-15T02:00:07.085Z","response_time":63,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["action-control","agent-governance","agent-runtime","agent-safety","agentic-ai","ai-agents","ai-governance","budget","budget-control","chat-client","cost-control","enterprise","java","llm","llmops","multi-tenant","runtime-authority","spring-ai","spring-boot"],"created_at":"2026-06-15T05:34:52.075Z","updated_at":"2026-06-15T05:34:52.986Z","avatar_url":"https://github.com/runcycles.png","language":"Java","funding_links":[],"categories":["Code \u0026 Examples"],"sub_categories":["Extensions and Forks"],"readme":"[![CI](https://github.com/runcycles/cycles-spring-ai-starter/actions/workflows/ci.yml/badge.svg)](https://github.com/runcycles/cycles-spring-ai-starter/actions)\n[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)\n[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/runcycles/cycles-spring-ai-starter/actions)\n\n# Cycles Spring AI Starter — runtime authority for Spring AI agents\n\n**Spring AI advisor + auto-configuration that adds budget enforcement to `ChatClient` invocations.** Integrates with the [Cycles Protocol](https://github.com/runcycles/cycles-protocol) for runtime authority over LLM spend, multi-tenant agent governance, and tamper-evident audit. Built for production Spring AI applications that need to gate LLM calls *before* they hit the provider.\n\nPer-call lifecycle: **reserve → call → commit** on success, **reserve → call → release** on exception. When the Cycles server denies the reservation, the LLM call never happens and a `CyclesBudgetDeniedException` is thrown. Compatible with Java 21+, Spring Boot 3.5+, and Spring AI 1.0+.\n\n## Quick start\n\n### 1. Add the dependency\n\n```xml\n\u003cdependency\u003e\n    \u003cgroupId\u003eio.runcycles\u003c/groupId\u003e\n    \u003cartifactId\u003ecycles-spring-ai-starter\u003c/artifactId\u003e\n    \u003cversion\u003e0.3.1\u003c/version\u003e\n\u003c/dependency\u003e\n```\n\nThis dependency transitively pulls in [`cycles-client-java-spring`](https://github.com/runcycles/cycles-spring-boot-starter) which provides the underlying HTTP client to the Cycles server.\n\n### 2. Configure connection + subject\n\nIn `application.yml`:\n\n```yaml\ncycles:\n  base-url: http://localhost:7878      # Cycles server URL\n  api-key:  ${CYCLES_API_KEY}          # provisioned via Cycles Admin\n  tenant:    acme-corp                 # subject defaults applied to every call\n  workspace: production\n  app:       order-agent\n\ncycles.spring-ai:\n  enabled: true                        # default true; set false to bypass\n  default-estimate: 1000               # default per-call estimate (USD_MICROCENTS)\n  estimate-unit: USD_MICROCENTS        # also accepts TOKENS, CREDITS, RISK_POINTS\n  action-kind: llm.chat\n  action-name: spring-ai-chat\n  fail-open: false                     # true = log + proceed on transport errors\n```\n\nThe first block (`cycles.*`) is owned by the underlying `cycles-client-java-spring` SDK; the second block (`cycles.spring-ai.*`) is owned by this starter.\n\n### 3. Use ChatClient normally\n\n```java\n@Service\npublic class OrderAgent {\n    private final ChatClient chatClient;\n\n    public OrderAgent(ChatClient.Builder builder) {\n        this.chatClient = builder.build();\n    }\n\n    public String summarize(String order) {\n        // Cycles reserves budget BEFORE this call hits the LLM provider.\n        // If the budget is exhausted, CyclesBudgetDeniedException is thrown\n        // and the LLM call never happens. On success, usage is committed\n        // back to Cycles. On exception, the reservation is released.\n        return chatClient.prompt()\n                .user(\"Summarize: \" + order)\n                .call()\n                .content();\n    }\n}\n```\n\nNo code changes to your call sites. The advisor is auto-attached to every `ChatClient` built from the auto-configured `ChatClient.Builder` via a `ChatClientCustomizer` bean.\n\n### 4. (Optional) Gate tool invocations\n\nFor agents that call tools, wrap each `ToolCallback` with the auto-configured `CyclesToolGate` to reserve / commit / release per tool call. Tool reservations report `tool.call` / `spring-ai-tool:\u003cname\u003e` action labels so they're separable from chat reservations in audit history.\n\n```java\n@Configuration\nclass ToolWiring {\n    @Bean\n    ToolCallback getWeatherTool(CyclesToolGate cyclesToolGate) {\n        ToolCallback raw = MethodToolCallback.builder()\n                .toolDefinition(ToolDefinition.builder().name(\"get_weather\").build())\n                .toolMethod(...)\n                .build();\n        return cyclesToolGate.wrap(raw); // ← Cycles-gated\n    }\n}\n```\n\nTool gating is opt-in: Spring AI doesn't provide a hook to auto-decorate every registered tool, so you choose which tools to gate. Currently tool reservations commit `default-estimate` as actual (tool callbacks don't expose token usage to the gate).\n\n### 5. (Optional) Cycles attribution on observability traces\n\nThe auto-configured `CyclesChatClientObservationConvention` appends low-cardinality Cycles attribution tags (`cycles.tenant`, `cycles.workspace`, `cycles.app`, `cycles.action_kind`, `cycles.action_name`) to every chat-client trace. Apply it explicitly on a `ChatClient.Builder` to opt in:\n\n```java\n@Service\nclass TracedAgent {\n    private final ChatClient chatClient;\n\n    TracedAgent(ChatClient.Builder builder, CyclesChatClientObservationConvention cyclesConvention) {\n        this.chatClient = builder\n                .observationConvention(cyclesConvention)\n                .build();\n    }\n    // ...\n}\n```\n\nThe bean is auto-configured but NOT auto-attached — applying a convention has cross-cutting trace-visibility implications that should be a deliberate user decision.\n\n## How it works\n\n**Non-streaming chat** (`chatClient.prompt(...).call()`):\n\n| Step | Cycles wire call | Spring AI insertion point |\n|---|---|---|\n| Pre-call | `POST /v1/reservations` with subject + action + estimate | `CallAdvisor.adviseCall(...)` runs at `HIGHEST_PRECEDENCE + 100` |\n| Call | (advisor delegates to `chain.nextCall(request)`) | Spring AI continues advisor chain → provider call |\n| Commit on success | `POST /v1/reservations/{id}/commit` with actual amount | After `chain.nextCall` returns |\n| Release on error | `POST /v1/reservations/{id}/release` with reason | Catch block re-throws original after release |\n\n**Streaming chat** (`chatClient.prompt(...).stream()`) — same lifecycle adapted to the reactive signal model. The entire pipeline is wrapped in `Flux.defer(...)` so reservation state is per-subscription (no leak when the Flux is assembled but never subscribed; resubscribing gets a fresh reservation):\n\n| Step | Cycles wire call | Reactor signal |\n|---|---|---|\n| Pre-stream | `POST /v1/reservations` | On subscription (inside `Flux.defer`). Reservation failures (denial, transport) surface as `onError` to the subscriber — the reactive-idiomatic shape; handle via `.onErrorResume(...)`. |\n| Stream | (advisor passes chunks through, tracking last seen) | `doOnNext(lastResponse::set)` |\n| Commit on complete | `POST /v1/reservations/{id}/commit` with usage from the last chunk | `concatWith(Mono.defer(...))` after the upstream emits `onComplete`. Commit runs **before** the subscriber observes terminal completion, so a fail-closed commit failure correctly surfaces as `onError` (the way the non-streaming advisor fails the call). |\n| Release on error | `POST /v1/reservations/{id}/release` | `doOnError` |\n| Release on cancel | `POST /v1/reservations/{id}/release` | `doOnCancel` |\n| Release on assembly failure | `POST /v1/reservations/{id}/release` | If `chain.nextStream(request)` throws synchronously after we reserved, we release and re-throw. |\n\n**Tool invocations** (when wrapped via `CyclesToolGate.wrap`):\n\n| Step | Cycles wire call | Tool insertion point |\n|---|---|---|\n| Pre-call | `POST /v1/reservations` with `tool.call` action kind | Before `delegate.call(...)` |\n| Call | (wrapper delegates to the wrapped tool) | Spring AI invokes the tool |\n| Commit on success | `POST /v1/reservations/{id}/commit` with `default-estimate` as actual | After delegate returns |\n| Release on exception | `POST /v1/reservations/{id}/release` | Wrapper re-throws original after release |\n\nBoth chat advisors are registered automatically via Spring AI's `ChatClientCustomizer` mechanism — `ChatClientAutoConfiguration` discovers customizer beans and applies them to the builder. **Simply exposing a `CallAdvisor` bean is not enough** in Spring AI 1.0+ — the customizer is the supported wiring path. The tool gate and observation convention are exposed as beans for explicit opt-in (see Quick Start steps 4 and 5).\n\n## Compatibility\n\n- **Java**: 21+\n- **Spring Boot**: 3.5.x\n- **Spring AI**: 1.0.x (BOM-managed; tested compatible with 1.1.x via the post-scaffold Dependabot bump to 1.1.6)\n\n## What's new in `0.3.0`\n\nThree new extension points and a trace-correlation tag, on top of v0.2.0's full feature surface:\n\n- ✅ **Pluggable `SubjectResolver`** — multi-tenant agents can route the Cycles `Subject` per call (tenant from `@AuthenticationPrincipal`, request header, thread-local, etc.) instead of using the static property defaults. Register a `SubjectResolver` bean and the auto-config's default backs off via `@ConditionalOnMissingBean`. See [Extension points](#extension-points) below.\n- ✅ **Pluggable `PromptTokenEstimator`** — replace the v0.2.0 `chars / 4` heuristic with real BPE tokenization. The starter ships a jtokkit-based estimator (`cl100k_base` / `o200k_base` etc. — opt in via `cycles.spring-ai.token-estimator-encoding`) or you can supply your own bean for provider-specific tokenizers.\n- ✅ **`cycles.reservation_id` on chat traces** — the `CyclesChatClientObservationConvention` now emits the active reservation id as a high-cardinality KeyValue on every chat-client observation, enabling trace ↔ Cycles reservation correlation in your tracing backend. Opt-out via `cycles.spring-ai.emit-reservation-id-on-trace=false`.\n- ✅ **End-to-end integration test** — the test bundle now boots a Spring context with the real auto-configuration and verifies the advisor attachment + reserve/commit lifecycle through a stub `ChatModel`. Closes the \"what if a regression breaks the wiring but unit tests still pass?\" gap.\n\n## What's new in `0.2.0`\n\nAll known limitations from v0.1.0 are addressed:\n\n- ✅ **Streaming chat gating.** `CyclesBudgetStreamAdvisor` mirrors the lifecycle of the non-streaming advisor for `chatClient.prompt(...).stream()` invocations. Reserves before subscribing; commits on stream complete; releases on error or subscriber cancellation. Both advisors are auto-attached to the auto-configured `ChatClient.Builder`.\n- ✅ **Real `ChatResponse.Usage` extraction on commit** — when the LLM provider returns usage and either `input-cost-per-token` / `output-cost-per-token` are configured (or `estimate-unit=TOKENS`), the advisor commits the actual cost computed from tokens rather than the estimate. Falls back to estimate-as-actual when usage data is missing. Applies to both the call and stream advisors (the stream advisor uses the last chunk that carried usage).\n- ✅ **Prompt-based per-call estimate.** When `cycles.spring-ai.estimate-from-prompt=true` and one of the cost-per-token rates is configured, the pre-call reservation is sized from the prompt's character count (chars / 4 → tokens) rather than the fixed `default-estimate`. Falls back to `default-estimate` when the prompt is empty or rates are zero.\n- ✅ **`ToolCallback` decoration.** `CyclesToolCallback` wraps any Spring AI `ToolCallback` with the same reserve / commit / release lifecycle. Users opt in via the auto-configured `CyclesToolGate.wrap(...)` factory. Tool reservations report distinct `tool.call` / `spring-ai-tool:\u003cname\u003e` action labels so they're separable from chat reservations in audit history.\n- ✅ **`ObservationConvention` for chat-client traces.** `CyclesChatClientObservationConvention` extends Spring AI's default convention and appends low-cardinality Cycles attribution tags (`cycles.tenant`, `cycles.workspace`, `cycles.app`, `cycles.action_kind`, `cycles.action_name`) to every chat-client observation. Auto-configured as a bean but NOT auto-attached — users apply it explicitly via `chatClientBuilder.observationConvention(cyclesConvention)`.\n\n## Configuration reference\n\n| Property | Default | Description |\n|---|---|---|\n| `cycles.spring-ai.enabled` | `true` | Master switch. Set false to disable Cycles wiring entirely. |\n| `cycles.spring-ai.default-estimate` | `1000` | Default per-call estimate, in the configured unit. Used unless `estimate-from-prompt=true` derives a per-call value from prompt size. |\n| `cycles.spring-ai.estimate-unit` | `USD_MICROCENTS` | Unit for the estimate. Cycles `Unit` enum values: `USD_MICROCENTS`, `TOKENS`, `CREDITS`, `RISK_POINTS`. |\n| `cycles.spring-ai.action-kind` | `llm.chat` | Action.kind label reported to Cycles. |\n| `cycles.spring-ai.action-name` | `spring-ai-chat` | Action.name label reported to Cycles. |\n| `cycles.spring-ai.fail-open` | `false` | When true, transport errors against Cycles are logged and the LLM call proceeds. Budget denials are always surfaced. |\n| `cycles.spring-ai.input-cost-per-token` | `0` | Per-input-token cost in the estimate unit. When set (with `output-cost-per-token`), the advisor commits actual token-based cost instead of the estimate. Example: `250` (= $2.50/1M tokens for OpenAI gpt-4o input, since 1 USD = 100,000,000 USD_MICROCENTS). |\n| `cycles.spring-ai.output-cost-per-token` | `0` | Per-output-token cost. Example: `1000` (= $10.00/1M tokens for OpenAI gpt-4o output). |\n| `cycles.spring-ai.estimate-from-prompt` | `false` | When `true` and at least one cost-per-token rate is set, sizes the pre-call reservation from the prompt char count (`chars / 4` × combined rate). Falls back to `default-estimate` when the prompt is empty or rates are zero. |\n| `cycles.spring-ai.tool-action-kind` | `tool.call` | Action.kind label reported for `CyclesToolCallback`-wrapped tool invocations (distinct from chat's `action-kind`). |\n| `cycles.spring-ai.tool-action-name-prefix` | `spring-ai-tool:` | Prefix prepended to the wrapped tool's name to produce the action.name label (e.g. `spring-ai-tool:get_weather`). |\n| `cycles.spring-ai.token-estimator-encoding` | _unset_ | When set AND jtokkit is on the classpath, swaps the default chars/4 prompt-token estimator for real BPE encoding. Values: `cl100k_base` (gpt-3.5-turbo, gpt-4), `o200k_base` (gpt-4o family), `p50k_base` / `p50k_edit` / `r50k_base` (older models). Requires adding `com.knuddels:jtokkit:1.1.0` to your app's pom; the dep is `optional=true` on this starter. |\n| `cycles.spring-ai.emit-reservation-id-on-trace` | `true` | When the `CyclesChatClientObservationConvention` is applied, emit the active `cycles.reservation_id` as a high-cardinality KeyValue on chat-client observations (enables trace ↔ reservation correlation). Set false to omit when your tracing backend charges by unique tag-value combinations. |\n\nConnection + subject properties (`cycles.base-url`, `cycles.api-key`, `cycles.tenant`, etc.) come from [`cycles-client-java-spring`](https://github.com/runcycles/cycles-spring-boot-starter) — see that repo's README for the full list.\n\n## Extension points\n\nThe starter exposes three pluggable beans so you can replace the defaults without touching the advisor code. Each backs off via `@ConditionalOnMissingBean`, so registering your own bean is the only thing you need to do.\n\n### Per-call subject routing — `SubjectResolver`\n\nBy default the starter reads tenant/workspace/app/etc. from `cycles.*` properties on every reservation, so every call from a given app gets the same Cycles `Subject`. Multi-tenant SaaS agents need per-request attribution. Register a `SubjectResolver` bean and the advisor calls it per request:\n\n```java\n@Bean\npublic SubjectResolver tenantAwareSubjectResolver(CyclesProperties defaults) {\n    return request -\u003e {\n        var auth = SecurityContextHolder.getContext().getAuthentication();\n        String tenant = (auth != null \u0026\u0026 auth.isAuthenticated()) ? auth.getName() : defaults.getTenant();\n        return Subject.builder()\n                .tenant(tenant)\n                .workspace(defaults.getWorkspace())\n                .app(defaults.getApp())\n                .build();\n    };\n}\n```\n\nThe `request` parameter is the originating `ChatClientRequest` (or `null` on the tool-gating path — tool callbacks don't carry a request). Implementations should handle `null` defensively, typically by falling back to the property defaults.\n\n### Custom prompt-token estimation — `PromptTokenEstimator`\n\nDefault is `CharsPerTokenEstimator` (the v0.2.0 `chars / 4` heuristic). For tighter estimates:\n\n**Option 1: jtokkit (real OpenAI BPE encoding).** Set the property:\n\n```yaml\ncycles:\n  spring-ai:\n    estimate-from-prompt: true\n    input-cost-per-token: 250                  # gpt-4o: $2.50/1M tokens × 100M microcents/USD = 250\n    output-cost-per-token: 1000                # gpt-4o: $10.00/1M tokens = 1000 microcents/token\n    token-estimator-encoding: cl100k_base   # or o200k_base for gpt-4o family\n```\n\nAdd the jtokkit dep to your app pom (it's `optional=true` on this starter so it's not pulled transitively):\n\n```xml\n\u003cdependency\u003e\n    \u003cgroupId\u003ecom.knuddels\u003c/groupId\u003e\n    \u003cartifactId\u003ejtokkit\u003c/artifactId\u003e\n    \u003cversion\u003e1.1.0\u003c/version\u003e\n\u003c/dependency\u003e\n```\n\nWhen the property is set without the dep on the classpath, the starter logs a WARN at app startup and falls back to chars/4 — you'll see the misconfig immediately, not at first call.\n\n**Option 2: custom bean.** Register your own `PromptTokenEstimator` for provider-specific tokenizers or domain-aware heuristics:\n\n```java\n@Bean\npublic PromptTokenEstimator anthropicTokenEstimator() {\n    return request -\u003e /* count tokens using Anthropic's tokenizer */;\n}\n```\n\n### Trace ↔ reservation correlation\n\nThe `CyclesChatClientObservationConvention` (Quick Start step 5) emits `cycles.reservation_id` as a high-cardinality KeyValue on every chat-client observation when applied. The advisor stores the reservation id in `request.context()` after a successful reserve; the convention reads it at observation-stop time. Disable via:\n\n```yaml\ncycles:\n  spring-ai:\n    emit-reservation-id-on-trace: false\n```\n\nThe low-cardinality Cycles attribution tags (`cycles.tenant`, `cycles.workspace`, etc.) are always emitted by the convention regardless of this setting.\n\n## Relationship to cycles-spring-boot-starter\n\nThe two Java integrations are **complementary, not competing** — they target different layers of the same problem. This starter actually *depends on* `cycles-spring-boot-starter` and reuses its `CyclesClient`, `CyclesProperties`, and connection plumbing.\n\n### What each one is\n\n| Aspect | [`cycles-spring-boot-starter`](https://github.com/runcycles/cycles-spring-boot-starter) | `cycles-spring-ai-starter` (this repo) |\n|---|---|---|\n| Maven artifact | `io.runcycles:cycles-client-java-spring` | `io.runcycles:cycles-spring-ai-starter` |\n| Integration mechanism | Spring AOP via `@Cycles` annotation | Spring AI `CallAdvisor` + `ChatClientCustomizer` |\n| Where it intercepts | Any Java method you annotate | Every `chatClient.prompt(...).call()` invocation |\n| Granularity | Method-level, explicit opt-in | Framework-level, transparent |\n| Call-site changes | Yes — annotate methods with `@Cycles` | No — wired automatically |\n| Estimate computation | SpEL: `@Cycles(\"#tokens * 10\")` (dynamic per-call) | `default-estimate`, or prompt-char × token-rate when `estimate-from-prompt=true` |\n| Subject routing | SpEL: can pull tenant from method args | Property defaults, or per-call via a custom `SubjectResolver` bean (see [Extension points](#extension-points)) |\n| Knows about LLMs? | No — generic | Yes — Spring AI ChatClient specific |\n| Scope | Any cost-incurring Java code | Only Spring AI chat calls |\n\nIn one line: the Java/Spring starter is a **method-level** integration where you decide where to put the gates. This starter is a **framework-level** integration where every Spring AI call surface is gated transparently.\n\n### When to use which\n\n**Use [`cycles-spring-boot-starter`](https://github.com/runcycles/cycles-spring-boot-starter) when:**\n\n- You call LLMs through code that is **not** Spring AI's `ChatClient` — direct HTTP calls, custom OpenAI / Anthropic / Bedrock SDKs, LangChain4j, in-house wrappers, etc.\n- You want **per-method dynamic estimates** via SpEL (e.g. `@Cycles(\"#tokens * 10\")` where `#tokens` is a method arg).\n- You want **per-method subject routing** — extract tenant from a DTO, request context, or thread-local.\n- You want **explicit control** over which methods are gated, not blanket coverage.\n- You're cost-gating non-LLM operations: vector-store queries, document processing, third-party metered APIs.\n- You're not using Spring AI at all.\n\n**Use `cycles-spring-ai-starter` (this repo) when:**\n\n- You're using Spring AI's `ChatClient` as your LLM call surface.\n- You want **transparent gating** of every chat call without touching call sites.\n- You want **minimal integration friction** — add the dep, set 6 properties, done. (Per-call estimates from prompt size are available via `estimate-from-prompt=true`; for richer dynamic estimates use the `cycles-spring-boot-starter` SpEL surface.)\n\n**Use both when:**\n\n- You have a Spring AI app that *also* has non-Spring-AI cost-incurring code (e.g., a service method that runs a vector-store query and then a Spring AI chat call — the vector store has cost, the chat has cost).\n- They wire on different conditions and don't conflict at the bean-wiring layer.\n\nBecause this starter declares a dependency on `cycles-client-java-spring`, the `@Cycles` annotation is *always* on your classpath when you use this starter — no need to explicitly add the other dependency to use both.\n\n### ⚠️ The double-charge gotcha\n\nThe two starters are designed to coexist, but you can accidentally double-charge if you wrap a Spring AI chat call inside an `@Cycles`-annotated method:\n\n```java\n@Service\nclass SummaryService {\n    @Cycles(\"#tokens * 10\")                       // ← Reservation #1 (AOP)\n    public String summarize(String text, int tokens) {\n        return chatClient.prompt()                // ← Reservation #2 (Spring AI advisor)\n                         .user(text)\n                         .call()\n                         .content();\n    }\n}\n```\n\nThat method consumes budget *twice* for one user-perceivable operation. Both reservations charge against the same budget.\n\n**Rule of thumb:** pick one strategy per call path.\n\n| Your call path | Use |\n|---|---|\n| Spring AI `ChatClient.call()` directly | `cycles-spring-ai-starter` alone — don't also `@Cycles` the caller |\n| LLM via a non-Spring-AI client | `cycles-spring-boot-starter` with `@Cycles` on the method |\n| Non-LLM cost-incurring operation | `cycles-spring-boot-starter` with `@Cycles` on the method |\n| Method that *both* does non-LLM work *and* a Spring AI chat call | Either `@Cycles` (charging once for the whole method) **or** let the Spring AI advisor handle just the chat part — not both |\n\n### One-line recommendation\n\n- **Pure Spring AI app**: this starter alone. Transparent gating, no code changes.\n- **Pure non-Spring-AI Java/Spring app**: [`cycles-spring-boot-starter`](https://github.com/runcycles/cycles-spring-boot-starter) with `@Cycles` on the methods that cost money.\n- **Mixed**: depend on this starter (you get the other transitively), use `@Cycles` for non-LLM paths, let the Spring AI advisor handle Spring AI paths, and don't combine them on the same path.\n\n## Project layout\n\n```\ncycles-spring-ai-starter/\n├── cycles-spring-ai-starter/        ← the library (published to Maven Central)\n└── cycles-spring-ai-demo/           ← a runnable demo app (not published)\n```\n\n## Development\n\n```bash\nmvn -B verify --file cycles-spring-ai-starter/pom.xml\nmvn -B install --file cycles-spring-ai-starter/pom.xml -DskipTests\nmvn -B verify --file cycles-spring-ai-demo/pom.xml\n```\n\n(In Claude Code remote environments, use `mvn-proxy` instead of `mvn` — see [CLAUDE.md](./CLAUDE.md).)\n\n## Releasing\n\nThe project uses Maven [CI-friendly versions](https://maven.apache.org/maven-ci-friendly.html) via the `${revision}` property, driven from `.mvn/maven.config` at the repo root. Both poms (starter + demo) declare `\u003cversion\u003e${revision}\u003c/version\u003e` and the demo's dep on the starter uses `${revision}` as well, so a version bump is a single-line edit.\n\n```text\n# .mvn/maven.config (single source of truth — applies to every mvn invocation)\n-Drevision=X.Y.Z-SNAPSHOT\n```\n\nThe inline `\u003crevision\u003e` defaults in each pom's `\u003cproperties\u003e` block should be kept in lockstep with `.mvn/maven.config` so the flattened pom that ships to Maven Central doesn't carry stale `\u003crevision\u003e` metadata. (The `flatten-maven-plugin`'s `resolveCiFriendliesOnly` mode resolves `\u003cversion\u003e` but preserves the `\u003cproperties\u003e` block as-is, so an IDE or build that bypasses `.mvn/maven.config` reads the inline default — and the published pom carries it verbatim.)\n\nTo cut a release (concrete example: cutting `X.Y.Z` from a `X.Y.Z-SNAPSHOT` dev branch):\n\n1. Edit `.mvn/maven.config`: `-Drevision=X.Y.Z-SNAPSHOT` → `-Drevision=X.Y.Z`. Also bump the inline `\u003crevision\u003e` defaults in both poms to `X.Y.Z` to match. Commit and push to `main`.\n2. Create a **GitHub Release** for the new version (e.g. via `gh release create vX.Y.Z --generate-notes` or the GitHub UI). Creating the release also creates the tag if it doesn't exist. The publish workflow triggers on `release: [created]` — **pushing a bare tag does not trigger publishing**, only the release event does.\n3. The publish workflow checks `mvn help:evaluate -Dexpression=project.version` against the tag — both now read `X.Y.Z` from `.mvn/maven.config`, so the version-vs-tag gate passes and the artifact deploys to Maven Central.\n4. After the release ships, bump `.mvn/maven.config` and the inline pom `\u003crevision\u003e` defaults to the next SNAPSHOT (e.g. `X.Y.Z+1-SNAPSHOT` or `X.Y+1.0-SNAPSHOT`). Commit, push.\n\nTo test a release build without publishing (e.g. to verify GPG signing works on a new key): trigger the publish workflow via `workflow_dispatch` from the Actions tab. That runs the `test-release-build` job only — no deploy.\n\nThe `flatten-maven-plugin` (configured on both poms in `resolveCiFriendliesOnly` mode) substitutes `${revision}` with the resolved value at `process-resources` and produces a `.flattened-pom.xml` that gets installed/deployed. Sonatype Central requires a literal version in the published pom; non-CI-friendly properties (BOM versions, etc.) remain as `${...}` in the published pom and are interpolated against the same pom's `\u003cproperties\u003e` block at consumer-resolve time — the standard behavior.\n\n## License\n\nApache 2.0 — see [LICENSE](./LICENSE).\n\n## Org-wide policies\n\n- [Security Policy](https://github.com/runcycles/.github/blob/main/SECURITY.md)\n- [Code of Conduct](https://github.com/runcycles/.github/blob/main/CODE_OF_CONDUCT.md)\n- [Contributing Guide](https://github.com/runcycles/.github/blob/main/CONTRIBUTING.md)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fruncycles%2Fcycles-spring-ai-starter","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fruncycles%2Fcycles-spring-ai-starter","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fruncycles%2Fcycles-spring-ai-starter/lists"}