Skip to content

Commit 7725cf7

Browse files
authored
Add create and update methods to client API (#36)
1 parent 876b047 commit 7725cf7

File tree

5 files changed

+978
-9
lines changed

5 files changed

+978
-9
lines changed

docs/PROMPTS.md

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,22 @@ Langfuse centralizes prompt management, allowing you to:
99
- A/B test prompt variations
1010
- Roll back to previous versions
1111
- Manage prompts across environments (dev/staging/prod)
12+
- **Create and update prompts programmatically** from your Ruby code
1213

1314
The SDK supports two prompt types:
1415
- **Text Prompts:** Single string templates (e.g., instructions, system messages)
1516
- **Chat Prompts:** Structured message arrays (e.g., conversation templates)
1617

18+
### Quick Reference
19+
20+
| Method | Description |
21+
|--------|-------------|
22+
| `get_prompt(name)` | Fetch a prompt by name |
23+
| `compile_prompt(name, variables:)` | Fetch and compile in one call |
24+
| `create_prompt(name:, prompt:, type:)` | Create a new prompt or version |
25+
| `update_prompt(name:, version:, labels:)` | Update labels on a version |
26+
| `list_prompts` | List all prompts in your project |
27+
1728
## Text Prompts
1829

1930
### Fetching Text Prompts
@@ -281,6 +292,126 @@ prompt.compile(
281292
# Context: Customer browsing electronics
282293
```
283294

295+
## Creating Prompts
296+
297+
You can create prompts programmatically without using the Langfuse UI.
298+
299+
### `create_prompt` - Create New Prompts
300+
301+
Create a new prompt or add a new version to an existing prompt:
302+
303+
```ruby
304+
# Create a text prompt
305+
prompt = client.create_prompt(
306+
name: "welcome-email",
307+
prompt: "Welcome {{name}}! Thank you for joining {{company}}.",
308+
type: :text,
309+
config: { model: "gpt-4o", temperature: 0.7 },
310+
labels: ["staging"],
311+
tags: ["email", "onboarding"]
312+
)
313+
314+
puts prompt.name # => "welcome-email"
315+
puts prompt.version # => 1
316+
```
317+
318+
If a prompt with the same name already exists, a new version is created:
319+
320+
```ruby
321+
# Create version 2 of "welcome-email"
322+
prompt_v2 = client.create_prompt(
323+
name: "welcome-email",
324+
prompt: "Hello {{name}}! Welcome to {{company}}. We're glad you're here!",
325+
type: :text,
326+
labels: ["staging"]
327+
)
328+
329+
puts prompt_v2.version # => 2
330+
```
331+
332+
### Creating Chat Prompts
333+
334+
Chat prompts use an array of message hashes:
335+
336+
```ruby
337+
prompt = client.create_prompt(
338+
name: "support-bot",
339+
prompt: [
340+
{ role: "system", content: "You are a {{role}} support assistant for {{company}}." },
341+
{ role: "user", content: "{{question}}" }
342+
],
343+
type: :chat,
344+
config: { model: "gpt-4o", max_tokens: 500 },
345+
labels: ["production"]
346+
)
347+
```
348+
349+
**Note:** Message roles can use either strings (`"system"`) or symbols (`:system`).
350+
351+
### Commit Messages
352+
353+
Track changes with optional commit messages:
354+
355+
```ruby
356+
prompt = client.create_prompt(
357+
name: "greeting",
358+
prompt: "Hi {{name}}, how can I help you today?",
359+
type: :text,
360+
commit_message: "Added friendlier tone per UX feedback"
361+
)
362+
```
363+
364+
Commit messages are visible in the Langfuse UI version history.
365+
366+
## Updating Prompts
367+
368+
### `update_prompt` - Update Labels
369+
370+
Update the labels on an existing prompt version. This is useful for promoting prompts through environments:
371+
372+
```ruby
373+
# Promote version 3 to production
374+
prompt = client.update_prompt(
375+
name: "greeting",
376+
version: 3,
377+
labels: ["production"]
378+
)
379+
380+
puts prompt.labels # => ["production"]
381+
```
382+
383+
**Note:** Only labels can be updated. Prompt content is immutable after creation—create a new version instead.
384+
385+
### Promotion Workflow Example
386+
387+
A typical promotion workflow:
388+
389+
```ruby
390+
# 1. Create new version in staging
391+
new_prompt = client.create_prompt(
392+
name: "checkout-flow",
393+
prompt: "Complete your purchase of {{product}}...",
394+
type: :text,
395+
labels: ["staging"]
396+
)
397+
398+
# 2. Test in staging environment...
399+
400+
# 3. Promote to production
401+
client.update_prompt(
402+
name: "checkout-flow",
403+
version: new_prompt.version,
404+
labels: ["production"]
405+
)
406+
407+
# 4. Remove old production label if needed
408+
client.update_prompt(
409+
name: "checkout-flow",
410+
version: old_version,
411+
labels: [] # Empty array removes all labels
412+
)
413+
```
414+
284415
## Convenience Methods
285416

286417
### `compile_prompt` - Fetch and Compile
@@ -437,4 +568,4 @@ See [TRACING.md](TRACING.md) for more tracing patterns.
437568
- [TRACING.md](TRACING.md) - Tracking prompt usage in traces
438569
- [CACHING.md](CACHING.md) - Optimizing prompt fetch performance
439570
- [ERROR_HANDLING.md](ERROR_HANDLING.md) - Handling prompt errors
440-
- [API_REFERENCE.md](API_REFERENCE.md) - Complete method signatures
571+
- [API_REFERENCE.md](API_REFERENCE.md) - Complete method signatures for all prompt methods

lib/langfuse/api_client.rb

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require "faraday/retry"
55
require "base64"
66
require "json"
7+
require "uri"
78

89
module Langfuse
910
# HTTP client for Langfuse API
@@ -128,6 +129,84 @@ def get_prompt(name, version: nil, label: nil)
128129
end
129130
end
130131

132+
# Create a new prompt (or new version if prompt with same name exists)
133+
#
134+
# @param name [String] The prompt name
135+
# @param prompt [String, Array<Hash>] The prompt content
136+
# @param type [String] Prompt type ("text" or "chat")
137+
# @param config [Hash] Optional configuration (model params, etc.)
138+
# @param labels [Array<String>] Optional labels (e.g., ["production"])
139+
# @param tags [Array<String>] Optional tags
140+
# @param commit_message [String, nil] Optional commit message
141+
# @return [Hash] The created prompt data
142+
# @raise [UnauthorizedError] if authentication fails
143+
# @raise [ApiError] for other API errors
144+
#
145+
# @example Create a text prompt
146+
# api_client.create_prompt(
147+
# name: "greeting",
148+
# prompt: "Hello {{name}}!",
149+
# type: "text",
150+
# labels: ["production"]
151+
# )
152+
#
153+
# rubocop:disable Metrics/ParameterLists
154+
def create_prompt(name:, prompt:, type:, config: {}, labels: [], tags: [], commit_message: nil)
155+
path = "/api/public/v2/prompts"
156+
payload = {
157+
name: name,
158+
prompt: prompt,
159+
type: type,
160+
config: config,
161+
labels: labels,
162+
tags: tags
163+
}
164+
payload[:commitMessage] = commit_message if commit_message
165+
166+
response = connection.post(path, payload)
167+
handle_response(response)
168+
rescue Faraday::RetriableResponse => e
169+
logger.error("Faraday error: Retries exhausted - #{e.response.status}")
170+
handle_response(e.response)
171+
rescue Faraday::Error => e
172+
logger.error("Faraday error: #{e.message}")
173+
raise ApiError, "HTTP request failed: #{e.message}"
174+
end
175+
# rubocop:enable Metrics/ParameterLists
176+
177+
# Update labels for an existing prompt version
178+
#
179+
# @param name [String] The prompt name
180+
# @param version [Integer] The version number to update
181+
# @param labels [Array<String>] New labels (replaces existing). Required.
182+
# @return [Hash] The updated prompt data
183+
# @raise [ArgumentError] if labels is not an array
184+
# @raise [NotFoundError] if the prompt is not found
185+
# @raise [UnauthorizedError] if authentication fails
186+
# @raise [ApiError] for other API errors
187+
#
188+
# @example Promote a prompt to production
189+
# api_client.update_prompt(
190+
# name: "greeting",
191+
# version: 2,
192+
# labels: ["production"]
193+
# )
194+
def update_prompt(name:, version:, labels:)
195+
raise ArgumentError, "labels must be an array" unless labels.is_a?(Array)
196+
197+
path = "/api/public/v2/prompts/#{URI.encode_uri_component(name)}/versions/#{version}"
198+
payload = { newLabels: labels }
199+
200+
response = connection.patch(path, payload)
201+
handle_response(response)
202+
rescue Faraday::RetriableResponse => e
203+
logger.error("Faraday error: Retries exhausted - #{e.response.status}")
204+
handle_response(e.response)
205+
rescue Faraday::Error => e
206+
logger.error("Faraday error: #{e.message}")
207+
raise ApiError, "HTTP request failed: #{e.message}"
208+
end
209+
131210
# Send a batch of events to the Langfuse ingestion API
132211
#
133212
# Sends events (scores, traces, observations) to the ingestion endpoint.
@@ -180,7 +259,7 @@ def send_batch(events)
180259
# @raise [ApiError] for other API errors
181260
def fetch_prompt_from_api(name, version: nil, label: nil)
182261
params = build_prompt_params(version: version, label: label)
183-
path = "/api/public/v2/prompts/#{name}"
262+
path = "/api/public/v2/prompts/#{URI.encode_uri_component(name)}"
184263

185264
response = connection.get(path, params)
186265
handle_response(response)
@@ -215,7 +294,9 @@ def build_connection(timeout: nil)
215294
# Retries transient errors with exponential backoff:
216295
# - Max 2 retries (3 total attempts)
217296
# - Exponential backoff (0.05s * 2^retry_count)
218-
# - Retries GET requests and POST requests to batch endpoint (idempotent operations)
297+
# - Retries GET and PATCH requests (idempotent operations)
298+
# - Retries POST requests to batch endpoint (idempotent due to event UUIDs)
299+
# - Note: POST to create_prompt is NOT idempotent; retries may create duplicate versions
219300
# - Retries on: 429 (rate limit), 503 (service unavailable), 504 (gateway timeout)
220301
# - Does NOT retry on: 4xx errors (except 429), 5xx errors (except 503, 504)
221302
#
@@ -225,7 +306,7 @@ def retry_options
225306
max: 2,
226307
interval: 0.05,
227308
backoff_factor: 2,
228-
methods: %i[get post],
309+
methods: %i[get post patch],
229310
retry_statuses: [429, 503, 504],
230311
exceptions: [Faraday::TimeoutError, Faraday::ConnectionFailed]
231312
}
@@ -278,7 +359,7 @@ def build_prompt_params(version: nil, label: nil)
278359
# @raise [ApiError] for other error statuses
279360
def handle_response(response)
280361
case response.status
281-
when 200
362+
when 200, 201
282363
response.body
283364
when 401
284365
raise UnauthorizedError, "Authentication failed. Check your API keys."

0 commit comments

Comments
 (0)