tutorial.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
  2. import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
  3. import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/ai/route"
  4. import { OpenAI } from "@opencode-ai/ai/providers"
  5. /**
  6. * A runnable walkthrough of the LLM package use-site API.
  7. *
  8. * Run from `packages/ai` with an OpenAI key in the environment:
  9. *
  10. * OPENAI_API_KEY=... bun example/tutorial.ts
  11. *
  12. * The file is intentionally written as a normal TypeScript program. You can
  13. * hover imports and local values to see how the public API is typed.
  14. */
  15. const apiKey = Config.redacted("OPENAI_API_KEY")
  16. // 1. Pick a model. The provider helper records provider identity, protocol
  17. // choice, capabilities, deployment options, authentication, and defaults.
  18. const model = OpenAI.configure({
  19. apiKey,
  20. generation: { maxTokens: 160 },
  21. providerOptions: {
  22. openai: { store: false },
  23. },
  24. }).model("gpt-4o-mini")
  25. // 2. Build a provider-neutral request. This is useful when reusing one request
  26. // across generate and stream examples.
  27. //
  28. // Options can live on both the configured route/provider facade and the request:
  29. //
  30. // - `generation`: common controls such as max tokens, temperature, topP/topK,
  31. // penalties, seed, and stop sequences.
  32. // - `promptCacheKey`: stable cache affinity for protocols that support it.
  33. // - `providerOptions`: namespaced provider-native behavior. For example,
  34. // OpenAI store behavior, Anthropic thinking, Gemini thinking config, or
  35. // OpenRouter routing/reasoning.
  36. // - `http`: last-resort serializable overlays for final request body, headers,
  37. // and query params. Prefer typed `providerOptions` when a field is stable.
  38. //
  39. // Route/provider options are defaults. Request options override them for this call.
  40. const request = LLM.request({
  41. model,
  42. system: "You are concise and practical.",
  43. prompt: "Tell me a joke",
  44. generation: { maxTokens: 80, temperature: 0.7 },
  45. promptCacheKey: "tutorial-joke",
  46. })
  47. // 3. `generate` sends the request and collects the event stream into one
  48. // response object. `response.text` is the collected text output.
  49. const generateOnce = Effect.gen(function* () {
  50. const response = yield* LLM.generate(request)
  51. console.log("\n== generate ==")
  52. console.log("generated text:", response.text)
  53. console.log("usage", Formatter.formatJson(response.usage, { space: 2 }))
  54. })
  55. // 4. `stream` exposes provider output as common `LLMEvent`s for UIs that want
  56. // incremental text, reasoning, tool input, usage, or finish events.
  57. const streamText = LLM.stream(request).pipe(
  58. Stream.tap((event) =>
  59. Effect.sync(() => {
  60. if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`)
  61. if (event.type === "finish")
  62. process.stdout.write(
  63. `\nfinish: ${event.reason.normalized}${event.reason.raw ? ` (${event.reason.raw})` : ""}\n`,
  64. )
  65. }),
  66. ),
  67. Stream.runDrain,
  68. )
  69. // 5. Tools are typed with Effect Schema. Provider turns remain explicit:
  70. // advertise definitions on the request, stream one turn, dispatch local calls,
  71. // then persist/build follow-up history in the enclosing product flow.
  72. const tools = {
  73. get_weather: Tool.make({
  74. description: "Get current weather for a city.",
  75. parameters: Schema.Struct({ city: Schema.String }),
  76. success: Schema.Struct({ forecast: Schema.String }),
  77. execute: (input) => Effect.succeed({ forecast: `${input.city}: sunny, 72F` }),
  78. }),
  79. }
  80. const streamWithTools = Effect.gen(function* () {
  81. const request = LLM.request({
  82. model,
  83. prompt: "Use get_weather for San Francisco, then answer in one sentence.",
  84. generation: { maxTokens: 80, temperature: 0 },
  85. tools: Tool.toDefinitions(tools),
  86. })
  87. const events = Array.from(yield* LLM.stream(request).pipe(Stream.runCollect))
  88. for (const event of events) {
  89. if (event.type === "tool-call") console.log("tool call", event.name, event.input)
  90. if (event.type === "text-delta") process.stdout.write(event.text)
  91. if (event.type !== "tool-call" || event.providerExecuted) continue
  92. const dispatched = yield* ToolRuntime.dispatch(tools, event)
  93. console.log("tool result", event.name, dispatched.result)
  94. // A durable agent would persist these messages before starting another
  95. // raw model turn. This tutorial keeps the boundary visible instead.
  96. const followUp = LLMRequest.update(request, {
  97. messages: [
  98. ...request.messages,
  99. Message.assistant([event]),
  100. Message.tool({ ...event, result: dispatched.result }),
  101. ],
  102. })
  103. console.log("follow-up history messages:", followUp.messages.length)
  104. }
  105. })
  106. // 6. `generateObject` is the structured-output helper. It forces a synthetic
  107. // tool call internally, so the same call site works across providers instead of
  108. // depending on provider-specific JSON mode flags.
  109. const WeatherReport = Schema.Struct({
  110. city: Schema.String,
  111. forecast: Schema.String,
  112. highFahrenheit: Schema.Number,
  113. })
  114. const generateStructuredObject = Effect.gen(function* () {
  115. const response = yield* LLM.generateObject({
  116. model,
  117. system: "Return only structured weather data.",
  118. prompt: "Give me today's weather for San Francisco.",
  119. schema: WeatherReport,
  120. generation: { maxTokens: 120, temperature: 0 },
  121. })
  122. console.log("\n== generateObject ==")
  123. console.log(Formatter.formatJson(response.object, { space: 2 }))
  124. })
  125. // If the shape is only known at runtime, pass raw JSON Schema instead. The
  126. // `.object` type is `unknown`; callers that need static types should validate it.
  127. const generateDynamicObject = LLM.generateObject({
  128. model,
  129. prompt: "Extract the city and forecast from: San Francisco is sunny.",
  130. jsonSchema: {
  131. type: "object",
  132. properties: {
  133. city: { type: "string" },
  134. forecast: { type: "string" },
  135. },
  136. required: ["city", "forecast"],
  137. },
  138. })
  139. // -----------------------------------------------------------------------------
  140. // Part 2: provider composition with a fake provider
  141. // -----------------------------------------------------------------------------
  142. // A protocol is the provider-native API shape: common request -> body, response
  143. // frames -> common events. This fake one turns text prompts into a JSON body
  144. // and treats every SSE frame as output text.
  145. const FakeBody = Schema.Struct({
  146. model: Schema.String,
  147. input: Schema.String,
  148. })
  149. type FakeBody = Schema.Schema.Type<typeof FakeBody>
  150. const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
  151. // Protocol ids are open strings, so external packages can define their own
  152. // protocols without changing this package.
  153. id: "fake-echo",
  154. body: {
  155. schema: FakeBody,
  156. from: (request) =>
  157. Effect.succeed({
  158. model: request.model.id,
  159. input: request.messages
  160. .flatMap((message) => message.content)
  161. .filter((part) => part.type === "text")
  162. .map((part) => part.text)
  163. .join("\n"),
  164. }),
  165. },
  166. stream: {
  167. event: Schema.String,
  168. initial: () => undefined,
  169. step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
  170. onHalt: () => [{ type: "finish", reason: { normalized: "stop" } }],
  171. },
  172. })
  173. // An route is the runnable binding for that protocol. It adds the deployment
  174. // axes that the protocol deliberately does not know: URL, auth, and framing.
  175. const FakeAdapter = Route.make({
  176. id: "fake-echo",
  177. provider: "fake-echo",
  178. protocol: FakeProtocol,
  179. endpoint: Endpoint.path("/v1/echo", { baseURL: "https://fake.local" }),
  180. auth: Auth.passthrough,
  181. framing: Framing.sse,
  182. })
  183. // A provider module exports a configured facade. Configuration happens before
  184. // model selection; model selectors accept ids only.
  185. const FakeEcho = {
  186. id: ProviderID.make("fake-echo"),
  187. configure: () => ({
  188. id: ProviderID.make("fake-echo"),
  189. model: (id: string) => FakeAdapter.model({ id }),
  190. }),
  191. }
  192. // Provide the LLM runtime and the HTTP request executor once. Keep one path
  193. // enabled at a time so the tutorial can demonstrate generate, stream, or
  194. // tool-loop behavior without spending tokens on every example.
  195. const requestExecutorLayer = RequestExecutor.fetchLayer
  196. const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
  197. const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
  198. const program = Effect.gen(function* () {
  199. // yield* generateOnce
  200. // yield* streamText
  201. // yield* generateStructuredObject
  202. // yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
  203. yield* streamWithTools
  204. }).pipe(Effect.provide(Layer.mergeAll(llmDeps, llmClientLayer)))
  205. Effect.runPromise(program)